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:
parent
df7e461c91
commit
7d7bee8e77
14 changed files with 1982 additions and 955 deletions
12
src/App.vue
12
src/App.vue
|
|
@ -9,6 +9,7 @@ import 'vue-sonner/style.css'
|
||||||
import { auth } from '@/composables/useAuth'
|
import { auth } from '@/composables/useAuth'
|
||||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
||||||
import { nostrChat } from '@/composables/useNostrChat'
|
import { nostrChat } from '@/composables/useNostrChat'
|
||||||
|
import { useRelayHub } from '@/composables/useRelayHub'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -17,6 +18,9 @@ const showLoginDialog = ref(false)
|
||||||
// Initialize preloader
|
// Initialize preloader
|
||||||
const marketPreloader = useMarketPreloader()
|
const marketPreloader = useMarketPreloader()
|
||||||
|
|
||||||
|
// Initialize relay hub
|
||||||
|
const relayHub = useRelayHub()
|
||||||
|
|
||||||
// Hide navbar on login page
|
// Hide navbar on login page
|
||||||
const showNavbar = computed(() => {
|
const showNavbar = computed(() => {
|
||||||
return route.path !== '/login'
|
return route.path !== '/login'
|
||||||
|
|
@ -50,6 +54,14 @@ onMounted(async () => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize authentication:', error)
|
console.error('Failed to initialize authentication:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize relay hub
|
||||||
|
try {
|
||||||
|
await relayHub.initialize()
|
||||||
|
console.log('Relay hub initialized successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize relay hub:', error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for authentication changes and trigger preloading
|
// Watch for authentication changes and trigger preloading
|
||||||
|
|
|
||||||
257
src/components/RelayHubStatus.vue
Normal file
257
src/components/RelayHubStatus.vue
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
<template>
|
||||||
|
<div class="relay-hub-status">
|
||||||
|
<div class="status-header">
|
||||||
|
<h3>Nostr Relay Hub Status</h3>
|
||||||
|
<div class="connection-indicator" :class="connectionStatus">
|
||||||
|
{{ connectionStatus }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connection-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Status:</span>
|
||||||
|
<span class="value">{{ connectionStatus }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Connected Relays:</span>
|
||||||
|
<span class="value">{{ connectedRelayCount }}/{{ totalRelayCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Health:</span>
|
||||||
|
<span class="value">{{ connectionHealth.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row" v-if="error">
|
||||||
|
<span class="label">Error:</span>
|
||||||
|
<span class="value error">{{ error.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relay-list" v-if="relayStatuses.length > 0">
|
||||||
|
<h4>Relay Status</h4>
|
||||||
|
<div class="relay-item" v-for="relay in relayStatuses" :key="relay.url">
|
||||||
|
<div class="relay-url">{{ relay.url }}</div>
|
||||||
|
<div class="relay-status" :class="{ connected: relay.connected }">
|
||||||
|
{{ relay.connected ? 'Connected' : 'Disconnected' }}
|
||||||
|
</div>
|
||||||
|
<div class="relay-latency" v-if="relay.latency !== undefined">
|
||||||
|
{{ relay.latency }}ms
|
||||||
|
</div>
|
||||||
|
<div class="relay-error" v-if="relay.error">
|
||||||
|
{{ relay.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button @click="connect" :disabled="isConnected || connectionStatus === 'connecting'">
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
<button @click="disconnect" :disabled="!isConnected">
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
<button @click="reconnect" :disabled="connectionStatus === 'connecting'">
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subscription-info">
|
||||||
|
<h4>Active Subscriptions</h4>
|
||||||
|
<div class="subscription-count">{{ activeSubscriptions.size }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRelayHub } from '@/composables/useRelayHub'
|
||||||
|
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
connectionStatus,
|
||||||
|
relayStatuses,
|
||||||
|
error,
|
||||||
|
activeSubscriptions,
|
||||||
|
connectedRelayCount,
|
||||||
|
totalRelayCount,
|
||||||
|
connectionHealth,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
reconnect
|
||||||
|
} = useRelayHub()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.relay-hub-status {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-indicator {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-indicator.connected {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-indicator.connecting {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-indicator.disconnected {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-indicator.error {
|
||||||
|
background-color: #fecaca;
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-info {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-list {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-list h4 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-url {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #475569;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-status {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-status.connected {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-latency {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-error {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #dc2626;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: white;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:hover:not(:disabled) {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-info {
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-info h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-count {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b82f6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -33,6 +33,7 @@ const navigation = computed<NavigationItem[]>(() => [
|
||||||
{ name: t('nav.events'), href: '/events' },
|
{ name: t('nav.events'), href: '/events' },
|
||||||
{ name: t('nav.market'), href: '/market' },
|
{ name: t('nav.market'), href: '/market' },
|
||||||
{ name: t('nav.chat'), href: '/chat' },
|
{ name: t('nav.chat'), href: '/chat' },
|
||||||
|
{ name: 'Relay Hub', href: '/relay-hub-demo' },
|
||||||
])
|
])
|
||||||
|
|
||||||
// Compute total wallet balance
|
// Compute total wallet balance
|
||||||
|
|
@ -149,6 +150,10 @@ const handleLogout = async () => {
|
||||||
<Ticket class="h-4 w-4" />
|
<Ticket class="h-4 w-4" />
|
||||||
My Tickets
|
My Tickets
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem @click="() => router.push('/relay-hub-demo')" class="gap-2">
|
||||||
|
<MessageSquare class="h-4 w-4" />
|
||||||
|
Relay Hub Demo
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem @click="handleLogout" class="gap-2 text-destructive">
|
<DropdownMenuItem @click="handleLogout" class="gap-2 text-destructive">
|
||||||
<LogOut class="h-4 w-4" />
|
<LogOut class="h-4 w-4" />
|
||||||
|
|
@ -230,6 +235,11 @@ const handleLogout = async () => {
|
||||||
<Ticket class="h-4 w-4" />
|
<Ticket class="h-4 w-4" />
|
||||||
My Tickets
|
My Tickets
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" @click="() => router.push('/relay-hub-demo')"
|
||||||
|
class="w-full justify-start gap-2">
|
||||||
|
<MessageSquare class="h-4 w-4" />
|
||||||
|
Relay Hub Demo
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" @click="handleLogout"
|
<Button variant="ghost" size="sm" @click="handleLogout"
|
||||||
class="w-full justify-start gap-2 text-destructive">
|
class="w-full justify-start gap-2 text-destructive">
|
||||||
<LogOut class="h-4 w-4" />
|
<LogOut class="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
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 { config } from '@/lib/config'
|
||||||
import { nip19 } from 'nostr-tools'
|
|
||||||
|
|
||||||
// Nostr event kinds for market functionality
|
// Nostr event kinds for market functionality
|
||||||
const MARKET_EVENT_KINDS = {
|
const MARKET_EVENT_KINDS = {
|
||||||
|
|
@ -15,99 +15,68 @@ const MARKET_EVENT_KINDS = {
|
||||||
export function useMarket() {
|
export function useMarket() {
|
||||||
const nostrStore = useNostrStore()
|
const nostrStore = useNostrStore()
|
||||||
const marketStore = useMarketStore()
|
const marketStore = useMarketStore()
|
||||||
|
const relayHub = useRelayHub()
|
||||||
|
|
||||||
|
// State
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
const isConnected = ref(false)
|
const isConnected = ref(false)
|
||||||
|
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)
|
||||||
|
|
||||||
// Track processed event IDs to prevent duplicates (like nostr-market-app)
|
// Connection state
|
||||||
const processedEventIds = new Set<string>()
|
const connectionStatus = computed(() => {
|
||||||
|
if (isConnected.value) return 'connected'
|
||||||
|
if (nostrStore.isConnecting) return 'connecting'
|
||||||
|
if (nostrStore.error) return 'error'
|
||||||
|
return 'disconnected'
|
||||||
|
})
|
||||||
|
|
||||||
// Queue for products that arrive before their stalls
|
// Load market from naddr
|
||||||
const pendingProducts = ref<Array<{event: any, productData: any}>>([])
|
|
||||||
|
|
||||||
// Market loading state
|
|
||||||
const loadMarket = async (naddr: string) => {
|
const loadMarket = async (naddr: string) => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
marketStore.setLoading(true)
|
error.value = null
|
||||||
marketStore.setError(null)
|
|
||||||
|
|
||||||
console.log('Loading market with naddr:', naddr)
|
console.log('Loading market from naddr:', naddr)
|
||||||
|
|
||||||
// Decode naddr
|
// Parse naddr to get market data
|
||||||
const { type, data } = nip19.decode(naddr)
|
const marketData = {
|
||||||
console.log('Decoded naddr:', { type, data })
|
identifier: naddr.split(':')[2] || 'default',
|
||||||
|
pubkey: naddr.split(':')[1] || nostrStore.account?.pubkey || ''
|
||||||
if (type !== 'naddr' || data.kind !== MARKET_EVENT_KINDS.MARKET) {
|
|
||||||
throw new Error('Invalid market naddr')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('About to load market data...')
|
if (!marketData.pubkey) {
|
||||||
// Load market data from Nostr
|
throw new Error('No pubkey available for market')
|
||||||
await loadMarketData(data)
|
}
|
||||||
console.log('Market data loaded successfully')
|
|
||||||
|
await loadMarketData(marketData)
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err : new Error('Failed to load market')
|
||||||
console.error('Error loading market:', err)
|
console.error('Error loading market:', err)
|
||||||
marketStore.setError(err instanceof Error ? err.message : 'Failed to load market')
|
throw err
|
||||||
// Don't throw error, let the UI handle it gracefully
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
marketStore.setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load market data from Nostr events
|
||||||
const loadMarketData = async (marketData: any) => {
|
const loadMarketData = async (marketData: any) => {
|
||||||
try {
|
try {
|
||||||
console.log('Starting loadMarketData...')
|
console.log('Loading market data for:', marketData)
|
||||||
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)
|
|
||||||
|
|
||||||
// Fetch market configuration event
|
// Fetch market configuration event
|
||||||
const events = await client.fetchEvents({
|
const events = await relayHub.queryEvents([
|
||||||
|
{
|
||||||
kinds: [MARKET_EVENT_KINDS.MARKET],
|
kinds: [MARKET_EVENT_KINDS.MARKET],
|
||||||
authors: [marketData.pubkey],
|
authors: [marketData.pubkey],
|
||||||
'#d': [marketData.identifier]
|
'#d': [marketData.identifier]
|
||||||
})
|
}
|
||||||
|
])
|
||||||
|
|
||||||
console.log('Found market events:', events.length)
|
console.log('Found market events:', events.length)
|
||||||
|
|
||||||
|
|
@ -115,7 +84,7 @@ export function useMarket() {
|
||||||
const marketEvent = events[0]
|
const marketEvent = events[0]
|
||||||
console.log('Market event:', marketEvent)
|
console.log('Market event:', marketEvent)
|
||||||
|
|
||||||
const market: Market = {
|
const market = {
|
||||||
d: marketData.identifier,
|
d: marketData.identifier,
|
||||||
pubkey: marketData.pubkey,
|
pubkey: marketData.pubkey,
|
||||||
relays: config.market.supportedRelays,
|
relays: config.market.supportedRelays,
|
||||||
|
|
@ -128,7 +97,7 @@ export function useMarket() {
|
||||||
} else {
|
} else {
|
||||||
console.warn('No market events found')
|
console.warn('No market events found')
|
||||||
// Create a default market if none exists
|
// Create a default market if none exists
|
||||||
const market: Market = {
|
const market = {
|
||||||
d: marketData.identifier,
|
d: marketData.identifier,
|
||||||
pubkey: marketData.pubkey,
|
pubkey: marketData.pubkey,
|
||||||
relays: config.market.supportedRelays,
|
relays: config.market.supportedRelays,
|
||||||
|
|
@ -146,9 +115,9 @@ export function useMarket() {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading market config:', err)
|
console.error('Error loading market data:', err)
|
||||||
// Don't throw error, create default market instead
|
// Don't throw error, create default market instead
|
||||||
const market: Market = {
|
const market = {
|
||||||
d: marketData.identifier,
|
d: marketData.identifier,
|
||||||
pubkey: marketData.pubkey,
|
pubkey: marketData.pubkey,
|
||||||
relays: config.market.supportedRelays,
|
relays: config.market.supportedRelays,
|
||||||
|
|
@ -166,10 +135,9 @@ export function useMarket() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load stalls from market merchants
|
||||||
const loadStalls = async () => {
|
const loadStalls = async () => {
|
||||||
try {
|
try {
|
||||||
const client = nostrStore.getClient()
|
|
||||||
|
|
||||||
// Get the active market to filter by its merchants
|
// Get the active market to filter by its merchants
|
||||||
const activeMarket = marketStore.activeMarket
|
const activeMarket = marketStore.activeMarket
|
||||||
if (!activeMarket) {
|
if (!activeMarket) {
|
||||||
|
|
@ -186,69 +154,57 @@ export function useMarket() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch stall events from market merchants only
|
// Fetch stall events from market merchants only
|
||||||
const events = await client.fetchEvents({
|
const events = await relayHub.queryEvents([
|
||||||
|
{
|
||||||
kinds: [MARKET_EVENT_KINDS.STALL],
|
kinds: [MARKET_EVENT_KINDS.STALL],
|
||||||
authors: merchants
|
authors: merchants
|
||||||
})
|
}
|
||||||
|
])
|
||||||
|
|
||||||
console.log('Found stall events:', events.length)
|
console.log('Found stall events:', events.length)
|
||||||
|
|
||||||
// Group events by stall ID and keep only the most recent version
|
// Group events by stall ID and keep only the most recent version
|
||||||
const stallGroups = new Map<string, any[]>()
|
const stallGroups = new Map<string, any[]>()
|
||||||
|
events.forEach((event: any) => {
|
||||||
events.forEach(event => {
|
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||||
try {
|
if (stallId) {
|
||||||
const stallData = JSON.parse(event.content)
|
|
||||||
const stallId = stallData.id
|
|
||||||
|
|
||||||
if (!stallGroups.has(stallId)) {
|
if (!stallGroups.has(stallId)) {
|
||||||
stallGroups.set(stallId, [])
|
stallGroups.set(stallId, [])
|
||||||
}
|
}
|
||||||
stallGroups.get(stallId)!.push({ event, stallData })
|
stallGroups.get(stallId)!.push(event)
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to parse stall event:', err)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process each stall group, keeping only the most recent version
|
// Process each stall group
|
||||||
stallGroups.forEach((stallEvents, _stallId) => {
|
stallGroups.forEach((stallEvents, stallId) => {
|
||||||
// Sort by created_at timestamp (most recent first)
|
// Sort by created_at and take the most recent
|
||||||
stallEvents.sort((a, b) => b.event.created_at - a.event.created_at)
|
const latestEvent = stallEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
|
||||||
|
|
||||||
// Take the most recent version
|
try {
|
||||||
const { event, stallData } = stallEvents[0]
|
const stallData = JSON.parse(latestEvent.content)
|
||||||
|
const stall = {
|
||||||
console.log('Processing most recent stall event:', event)
|
id: stallId,
|
||||||
console.log('Parsed stall data:', stallData)
|
pubkey: latestEvent.pubkey,
|
||||||
|
name: stallData.name || 'Unnamed Stall',
|
||||||
const stall: Stall = {
|
description: stallData.description || '',
|
||||||
id: stallData.id, // Use the stall's unique ID from content, not the Nostr event ID
|
created_at: latestEvent.created_at,
|
||||||
pubkey: event.pubkey,
|
...stallData
|
||||||
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)
|
marketStore.addStall(stall)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to parse stall data:', err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process any pending products that might match the loaded stalls
|
|
||||||
processPendingProducts()
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading stalls:', err)
|
console.error('Error loading stalls:', err)
|
||||||
// Don't throw error, continue without stalls
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load products from market stalls
|
||||||
const loadProducts = async () => {
|
const loadProducts = async () => {
|
||||||
try {
|
try {
|
||||||
const client = nostrStore.getClient()
|
|
||||||
|
|
||||||
// Get the active market to filter by its merchants
|
|
||||||
const activeMarket = marketStore.activeMarket
|
const activeMarket = marketStore.activeMarket
|
||||||
if (!activeMarket) {
|
if (!activeMarket) {
|
||||||
console.warn('No active market found')
|
console.warn('No active market found')
|
||||||
|
|
@ -256,400 +212,333 @@ export function useMarket() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
const merchants = [...(activeMarket.opts.merchants || [])]
|
||||||
console.log('Loading products from market merchants:', merchants)
|
|
||||||
|
|
||||||
if (merchants.length === 0) {
|
if (merchants.length === 0) {
|
||||||
console.log('No merchants in market, skipping product loading')
|
console.log('No merchants in market, skipping product loading')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch product events from market merchants only
|
// Fetch product events from market merchants
|
||||||
const events = await client.fetchEvents({
|
const events = await relayHub.queryEvents([
|
||||||
|
{
|
||||||
kinds: [MARKET_EVENT_KINDS.PRODUCT],
|
kinds: [MARKET_EVENT_KINDS.PRODUCT],
|
||||||
authors: merchants
|
authors: merchants
|
||||||
})
|
}
|
||||||
|
])
|
||||||
|
|
||||||
console.log('Found product events from market merchants:', events.length)
|
console.log('Found product events:', events.length)
|
||||||
|
|
||||||
// Group events by product ID and keep only the most recent version
|
// Group events by product ID and keep only the most recent version
|
||||||
const productGroups = new Map<string, any[]>()
|
const productGroups = new Map<string, any[]>()
|
||||||
|
events.forEach((event: any) => {
|
||||||
events.forEach(event => {
|
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||||
try {
|
if (productId) {
|
||||||
const productData = JSON.parse(event.content)
|
|
||||||
const productId = productData.id
|
|
||||||
|
|
||||||
if (!productGroups.has(productId)) {
|
if (!productGroups.has(productId)) {
|
||||||
productGroups.set(productId, [])
|
productGroups.set(productId, [])
|
||||||
}
|
}
|
||||||
productGroups.get(productId)!.push({ event, productData })
|
productGroups.get(productId)!.push(event)
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to parse product event:', err)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process each product group, keeping only the most recent version
|
// Process each product group
|
||||||
productGroups.forEach((productEvents, _productId) => {
|
productGroups.forEach((productEvents, productId) => {
|
||||||
// Sort by created_at timestamp (most recent first)
|
// Sort by created_at and take the most recent
|
||||||
productEvents.sort((a, b) => b.event.created_at - a.event.created_at)
|
const latestEvent = productEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
|
||||||
|
|
||||||
// Take the most recent version
|
try {
|
||||||
const { event, productData } = productEvents[0]
|
const productData = JSON.parse(latestEvent.content)
|
||||||
|
const product = {
|
||||||
console.log('Processing most recent product event:', event)
|
id: productId,
|
||||||
console.log('Parsed product data:', productData)
|
stall_id: productData.stall_id || 'unknown',
|
||||||
|
stallName: productData.stallName || 'Unknown Stall',
|
||||||
// Find stall by stall_id from product data, not by pubkey
|
name: productData.name || 'Unnamed Product',
|
||||||
const stall = marketStore.stalls.find(s => s.id === productData.stall_id)
|
description: productData.description || '',
|
||||||
console.log('Found stall for product:', stall)
|
price: productData.price || 0,
|
||||||
|
currency: productData.currency || 'sats',
|
||||||
if (stall) {
|
quantity: productData.quantity || 1,
|
||||||
const product: Product = {
|
images: productData.images || [],
|
||||||
id: productData.id, // Use the product's unique ID from content, not the Nostr event ID
|
categories: productData.categories || [],
|
||||||
stall_id: stall.id,
|
createdAt: latestEvent.created_at,
|
||||||
stallName: stall.name,
|
updatedAt: latestEvent.created_at
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Created product (most recent version):', product)
|
|
||||||
marketStore.addProduct(product)
|
marketStore.addProduct(product)
|
||||||
} else {
|
} catch (err) {
|
||||||
console.warn('No matching stall found for product:', {
|
console.warn('Failed to parse product data:', err)
|
||||||
productId: productData.id,
|
|
||||||
stallId: productData.stall_id,
|
|
||||||
availableStalls: marketStore.stalls.map(s => ({ id: s.id, name: s.name }))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading products:', 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 = () => {
|
const addSampleProducts = () => {
|
||||||
// Create a sample stall if none exists
|
const sampleProducts = [
|
||||||
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[] = [
|
|
||||||
{
|
{
|
||||||
id: 'sample-product-1',
|
id: 'sample-1',
|
||||||
stall_id: 'sample-stall-1',
|
stall_id: 'sample-stall',
|
||||||
stallName: 'Castle Tech',
|
stallName: 'Sample Stall',
|
||||||
name: 'Seed Signer',
|
pubkey: nostrStore.account?.pubkey || '',
|
||||||
description: 'Your Cyberpunk Cold Wallet',
|
name: 'Sample Product 1',
|
||||||
price: 100000,
|
description: 'This is a sample product for testing',
|
||||||
currency: 'sat',
|
price: 1000,
|
||||||
quantity: 15,
|
currency: 'sats',
|
||||||
|
quantity: 1,
|
||||||
images: [],
|
images: [],
|
||||||
categories: ['Hardware', 'Security'],
|
categories: [],
|
||||||
createdAt: Date.now() / 1000,
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
updatedAt: Date.now() / 1000
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sample-product-2',
|
id: 'sample-2',
|
||||||
stall_id: 'sample-stall-1',
|
stall_id: 'sample-stall',
|
||||||
stallName: 'Castle Tech',
|
stallName: 'Sample Stall',
|
||||||
name: 'Bitcoin Node',
|
pubkey: nostrStore.account?.pubkey || '',
|
||||||
description: 'Full Bitcoin node for maximum privacy',
|
name: 'Sample Product 2',
|
||||||
price: 50000,
|
description: 'Another sample product for testing',
|
||||||
currency: 'sat',
|
price: 2000,
|
||||||
quantity: 10,
|
currency: 'sats',
|
||||||
|
quantity: 1,
|
||||||
images: [],
|
images: [],
|
||||||
categories: ['Hardware', 'Networking'],
|
categories: [],
|
||||||
createdAt: Date.now() / 1000,
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
updatedAt: Date.now() / 1000
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
sampleProducts.forEach(product => {
|
sampleProducts.forEach(product => {
|
||||||
marketStore.addProduct(product)
|
marketStore.addProduct(product)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Added sample products:', sampleProducts.length)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribeToMarketUpdates = () => {
|
// Subscribe to market updates
|
||||||
|
const subscribeToMarketUpdates = (): (() => void) | null => {
|
||||||
try {
|
try {
|
||||||
const client = nostrStore.getClient()
|
|
||||||
|
|
||||||
// Get the active market to filter by its merchants
|
|
||||||
const activeMarket = marketStore.activeMarket
|
const activeMarket = marketStore.activeMarket
|
||||||
if (!activeMarket) {
|
if (!activeMarket) {
|
||||||
console.warn('No active market found for subscription')
|
console.warn('No active market found for subscription')
|
||||||
return () => {}
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
// Subscribe to market events
|
||||||
if (merchants.length === 0) {
|
const unsubscribe = relayHub.subscribe({
|
||||||
console.log('No merchants in market, skipping subscription')
|
id: `market-${activeMarket.d}`,
|
||||||
return () => {}
|
filters: [
|
||||||
}
|
{ kinds: [MARKET_EVENT_KINDS.MARKET] },
|
||||||
|
{ kinds: [MARKET_EVENT_KINDS.STALL] },
|
||||||
console.log('Subscribing to updates from market merchants:', merchants)
|
{ kinds: [MARKET_EVENT_KINDS.PRODUCT] },
|
||||||
|
{ kinds: [MARKET_EVENT_KINDS.ORDER] }
|
||||||
// Subscribe to real-time market updates from market merchants only
|
],
|
||||||
const filters = [
|
onEvent: (event: any) => {
|
||||||
{
|
|
||||||
kinds: [MARKET_EVENT_KINDS.STALL, MARKET_EVENT_KINDS.PRODUCT],
|
|
||||||
authors: merchants,
|
|
||||||
since: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// Subscribe to each relay individually
|
|
||||||
const unsubscribes = config.market.supportedRelays.map(relay => {
|
|
||||||
const sub = client.poolInstance.subscribeMany(
|
|
||||||
[relay],
|
|
||||||
filters,
|
|
||||||
{
|
|
||||||
onevent: (event: any) => {
|
|
||||||
handleMarketEvent(event)
|
handleMarketEvent(event)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
return () => sub.close()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return a function that unsubscribes from all relays
|
return unsubscribe
|
||||||
return () => {
|
} catch (error) {
|
||||||
unsubscribes.forEach(unsub => unsub())
|
console.error('Failed to subscribe to market updates:', error)
|
||||||
}
|
return null
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error subscribing to market updates:', err)
|
|
||||||
// Return a no-op function if subscription fails
|
|
||||||
return () => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle incoming market events
|
||||||
const handleMarketEvent = (event: any) => {
|
const handleMarketEvent = (event: any) => {
|
||||||
// Skip if already processed
|
console.log('Received market event:', event)
|
||||||
if (processedEventIds.has(event.id)) return
|
|
||||||
processedEventIds.add(event.id)
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
|
case MARKET_EVENT_KINDS.MARKET:
|
||||||
|
// Handle market updates
|
||||||
|
break
|
||||||
case MARKET_EVENT_KINDS.STALL:
|
case MARKET_EVENT_KINDS.STALL:
|
||||||
|
// Handle stall updates
|
||||||
handleStallEvent(event)
|
handleStallEvent(event)
|
||||||
break
|
break
|
||||||
case MARKET_EVENT_KINDS.PRODUCT:
|
case MARKET_EVENT_KINDS.PRODUCT:
|
||||||
|
// Handle product updates
|
||||||
handleProductEvent(event)
|
handleProductEvent(event)
|
||||||
break
|
break
|
||||||
case MARKET_EVENT_KINDS.ORDER:
|
case MARKET_EVENT_KINDS.ORDER:
|
||||||
|
// Handle order updates
|
||||||
handleOrderEvent(event)
|
handleOrderEvent(event)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Error handling market event:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process pending products (products without stalls)
|
||||||
const processPendingProducts = () => {
|
const processPendingProducts = () => {
|
||||||
console.log('Processing pending products:', pendingProducts.value.length)
|
const productsWithoutStalls = products.value.filter(product => {
|
||||||
const remaining = pendingProducts.value.filter(({ productData }) => {
|
// Check if product has a stall tag
|
||||||
const stall = marketStore.stalls.find(s => s.id === productData.stall_id)
|
return !product.stall_id
|
||||||
if (stall) {
|
|
||||||
console.log('Found matching stall for pending product:', {
|
|
||||||
productId: productData.id,
|
|
||||||
stallId: stall.id,
|
|
||||||
stallName: stall.name
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const product: Product = {
|
if (productsWithoutStalls.length > 0) {
|
||||||
id: productData.id,
|
console.log('Found products without stalls:', productsWithoutStalls.length)
|
||||||
stall_id: stall.id,
|
// You could create default stalls or handle this as needed
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
pendingProducts.value = remaining
|
|
||||||
if (remaining.length > 0) {
|
|
||||||
console.log('Still pending products:', remaining.length)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle stall events
|
||||||
const handleStallEvent = (event: any) => {
|
const handleStallEvent = (event: any) => {
|
||||||
try {
|
try {
|
||||||
|
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||||
|
if (stallId) {
|
||||||
const stallData = JSON.parse(event.content)
|
const stallData = JSON.parse(event.content)
|
||||||
console.log('Processing stall event:', {
|
const stall = {
|
||||||
stallId: stallData.id,
|
id: stallId,
|
||||||
stallName: stallData.name,
|
|
||||||
merchantPubkey: event.pubkey
|
|
||||||
})
|
|
||||||
|
|
||||||
const stall: Stall = {
|
|
||||||
id: stallData.id, // Use stall ID from content, not event ID
|
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
name: stallData.name,
|
name: stallData.name || 'Unnamed Stall',
|
||||||
description: stallData.description,
|
description: stallData.description || '',
|
||||||
logo: stallData.logo,
|
created_at: event.created_at,
|
||||||
categories: stallData.categories,
|
...stallData
|
||||||
shipping: stallData.shipping
|
|
||||||
}
|
}
|
||||||
|
|
||||||
marketStore.addStall(stall)
|
marketStore.addStall(stall)
|
||||||
|
}
|
||||||
// Process any pending products that might match this stall
|
|
||||||
processPendingProducts()
|
|
||||||
} catch (err) {
|
} 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) => {
|
const handleProductEvent = (event: any) => {
|
||||||
try {
|
try {
|
||||||
|
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||||
|
if (productId) {
|
||||||
const productData = JSON.parse(event.content)
|
const productData = JSON.parse(event.content)
|
||||||
console.log('Processing product event:', {
|
const product = {
|
||||||
productId: productData.id,
|
id: productId,
|
||||||
productName: productData.name,
|
stall_id: productData.stall_id || 'unknown',
|
||||||
stallId: productData.stall_id,
|
stallName: productData.stallName || 'Unknown Stall',
|
||||||
merchantPubkey: event.pubkey
|
pubkey: event.pubkey,
|
||||||
})
|
name: productData.name || 'Unnamed Product',
|
||||||
|
description: productData.description || '',
|
||||||
// Find stall by stall_id from product data, not by pubkey
|
price: productData.price || 0,
|
||||||
const stall = marketStore.stalls.find(s => s.id === productData.stall_id)
|
currency: productData.currency || 'sats',
|
||||||
|
quantity: productData.quantity || 1,
|
||||||
if (stall) {
|
images: productData.images || [],
|
||||||
console.log('Found matching stall:', { stallId: stall.id, stallName: stall.name })
|
categories: productData.categories || [],
|
||||||
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,
|
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
updatedAt: event.created_at
|
updatedAt: event.created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
marketStore.addProduct(product)
|
marketStore.addProduct(product)
|
||||||
} else {
|
|
||||||
console.log('Stall not found yet, queuing product for later processing')
|
|
||||||
pendingProducts.value.push({ event, productData })
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) => {
|
const handleOrderEvent = (event: any) => {
|
||||||
try {
|
try {
|
||||||
const orderData = JSON.parse(event.content)
|
const orderData = JSON.parse(event.content)
|
||||||
// Handle order events (for future order management)
|
const order = {
|
||||||
console.log('Order event received:', orderData)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: addOrder method doesn't exist in the store, so we'll just log it
|
||||||
|
console.log('Received order event:', order)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to parse order event:', err)
|
console.warn('Failed to handle order event:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish a product
|
||||||
const publishProduct = async (_productData: any) => {
|
const publishProduct = async (_productData: any) => {
|
||||||
try {
|
// Implementation would depend on your event creation logic
|
||||||
|
console.log('Publishing product:', _productData)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish a stall
|
||||||
const publishStall = async (_stallData: any) => {
|
const publishStall = async (_stallData: any) => {
|
||||||
try {
|
// Implementation would depend on your event creation logic
|
||||||
|
console.log('Publishing stall:', _stallData)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connect to market
|
||||||
const connectToMarket = async () => {
|
const connectToMarket = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Checking Nostr connection...')
|
console.log('Connecting to market...')
|
||||||
console.log('Current connection state:', nostrStore.isConnected)
|
|
||||||
|
|
||||||
if (!nostrStore.isConnected) {
|
// Connect to relay hub
|
||||||
console.log('Connecting to Nostr relays...')
|
await relayHub.connect()
|
||||||
await nostrStore.connect()
|
isConnected.value = relayHub.isConnected.value
|
||||||
console.log('Connected to Nostr relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
isConnected.value = nostrStore.isConnected
|
|
||||||
console.log('Final connection state:', isConnected.value)
|
|
||||||
|
|
||||||
if (!isConnected.value) {
|
if (!isConnected.value) {
|
||||||
throw new Error('Failed to connect to Nostr relays')
|
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) {
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err : new Error('Failed to connect to market')
|
||||||
console.error('Error connecting to market:', err)
|
console.error('Error connecting to market:', err)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disconnect from market
|
||||||
const disconnectFromMarket = () => {
|
const disconnectFromMarket = () => {
|
||||||
// Cleanup subscriptions and connections
|
|
||||||
isConnected.value = false
|
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 {
|
return {
|
||||||
// State
|
// State
|
||||||
isLoading: readonly(isLoading),
|
isLoading: readonly(isLoading),
|
||||||
|
error: readonly(error),
|
||||||
isConnected: readonly(isConnected),
|
isConnected: readonly(isConnected),
|
||||||
|
connectionStatus: readonly(connectionStatus),
|
||||||
|
activeMarket: readonly(activeMarket),
|
||||||
|
markets: readonly(markets),
|
||||||
|
stalls: readonly(stalls),
|
||||||
|
products: readonly(products),
|
||||||
|
orders: readonly(orders),
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
loadMarket,
|
loadMarket,
|
||||||
connectToMarket,
|
connectToMarket,
|
||||||
disconnectFromMarket,
|
disconnectFromMarket,
|
||||||
|
addSampleProducts,
|
||||||
|
processPendingProducts,
|
||||||
publishProduct,
|
publishProduct,
|
||||||
publishStall,
|
publishStall,
|
||||||
subscribeToMarketUpdates
|
subscribeToMarketUpdates
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ export function useNostr(config?: NostrClientConfig) {
|
||||||
isConnecting,
|
isConnecting,
|
||||||
error,
|
error,
|
||||||
connect: store.connect,
|
connect: store.connect,
|
||||||
disconnect: store.disconnect,
|
disconnect: store.disconnect
|
||||||
getClient: store.getClient
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { ref, computed, readonly } from 'vue'
|
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 { hexToBytes } from '@/lib/utils/crypto'
|
||||||
import { getAuthToken } from '@/lib/config/lnbits'
|
import { getAuthToken } from '@/lib/config/lnbits'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
|
import { useRelayHub } from './useRelayHub'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|
@ -63,14 +64,14 @@ const saveUnreadData = (peerPubkey: string, data: UnreadMessageData): void => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNostrChat() {
|
export function useNostrChat() {
|
||||||
|
// Use the centralized relay hub
|
||||||
|
const relayHub = useRelayHub()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const isConnected = ref(false)
|
|
||||||
const messages = ref<Map<string, ChatMessage[]>>(new Map())
|
const messages = ref<Map<string, ChatMessage[]>>(new Map())
|
||||||
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
|
const currentUser = ref<{ pubkey: string; prvkey: string } | null>(null)
|
||||||
const processedMessageIds = ref(new Set<string>())
|
const processedMessageIds = ref(new Set<string>())
|
||||||
const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null)
|
const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null)
|
||||||
const pool = ref<SimplePool | null>(null)
|
|
||||||
|
|
||||||
// Reactive unread counts
|
// Reactive unread counts
|
||||||
const unreadCounts = ref<Map<string, number>>(new Map())
|
const unreadCounts = ref<Map<string, number>>(new Map())
|
||||||
|
|
@ -81,7 +82,8 @@ export function useNostrChat() {
|
||||||
// Store peers globally
|
// Store peers globally
|
||||||
const peers = ref<any[]>([])
|
const peers = ref<any[]>([])
|
||||||
|
|
||||||
// Computed
|
// Computed - use relay hub's connection status
|
||||||
|
const isConnected = computed(() => relayHub.isConnected.value)
|
||||||
const isLoggedIn = computed(() => !!currentUser.value)
|
const isLoggedIn = computed(() => !!currentUser.value)
|
||||||
|
|
||||||
// Get unread count for a peer
|
// Get unread count for a peer
|
||||||
|
|
@ -103,15 +105,6 @@ export function useNostrChat() {
|
||||||
return total
|
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
|
// Get latest message timestamp for a peer
|
||||||
const getLatestMessageTimestamp = (peerPubkey: string): number => {
|
const getLatestMessageTimestamp = (peerPubkey: string): number => {
|
||||||
return latestMessageTimestamps.value.get(peerPubkey) || 0
|
return latestMessageTimestamps.value.get(peerPubkey) || 0
|
||||||
|
|
@ -124,238 +117,163 @@ export function useNostrChat() {
|
||||||
|
|
||||||
// Update latest message timestamp for a peer
|
// Update latest message timestamp for a peer
|
||||||
const updateLatestMessageTimestamp = (peerPubkey: string, timestamp: number): void => {
|
const updateLatestMessageTimestamp = (peerPubkey: string, timestamp: number): void => {
|
||||||
const currentLatest = latestMessageTimestamps.value.get(peerPubkey) || 0
|
const current = latestMessageTimestamps.value.get(peerPubkey) || 0
|
||||||
if (timestamp > currentLatest) {
|
if (timestamp > current) {
|
||||||
latestMessageTimestamps.value.set(peerPubkey, timestamp)
|
latestMessageTimestamps.value.set(peerPubkey, timestamp)
|
||||||
// Force reactivity
|
|
||||||
latestMessageTimestamps.value = new Map(latestMessageTimestamps.value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update unread count for a peer
|
// Update unread count for a peer
|
||||||
const updateUnreadCount = (peerPubkey: string, count: number): void => {
|
const updateUnreadCount = (peerPubkey: string, count: number): void => {
|
||||||
if (count > 0) {
|
const current = unreadCounts.value.get(peerPubkey) || 0
|
||||||
unreadCounts.value.set(peerPubkey, count)
|
unreadCounts.value.set(peerPubkey, current + count)
|
||||||
} else {
|
|
||||||
unreadCounts.value.delete(peerPubkey)
|
// Save to localStorage
|
||||||
}
|
const unreadData = getUnreadData(peerPubkey)
|
||||||
// Force reactivity
|
unreadData.unreadCount = current + count
|
||||||
unreadCounts.value = new Map(unreadCounts.value)
|
saveUnreadData(peerPubkey, unreadData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark messages as read for a peer
|
// Mark messages as read for a peer
|
||||||
const markMessagesAsRead = (peerPubkey: string): void => {
|
const markMessagesAsRead = (peerPubkey: string): void => {
|
||||||
const currentTimestamp = Math.floor(Date.now() / 1000)
|
const current = unreadCounts.value.get(peerPubkey) || 0
|
||||||
|
if (current > 0) {
|
||||||
|
unreadCounts.value.set(peerPubkey, 0)
|
||||||
|
|
||||||
// Update last read timestamp, reset unread count, and clear processed message IDs
|
// Save to localStorage
|
||||||
const updatedData: UnreadMessageData = {
|
const unreadData = getUnreadData(peerPubkey)
|
||||||
lastReadTimestamp: currentTimestamp,
|
unreadData.unreadCount = 0
|
||||||
unreadCount: 0,
|
unreadData.lastReadTimestamp = Date.now()
|
||||||
processedMessageIds: new Set() // Clear processed messages when marking as read
|
saveUnreadData(peerPubkey, unreadData)
|
||||||
}
|
}
|
||||||
|
|
||||||
saveUnreadData(peerPubkey, updatedData)
|
|
||||||
updateUnreadCount(peerPubkey, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load unread counts from localStorage
|
// Load unread counts from localStorage
|
||||||
const loadUnreadCounts = (): void => {
|
// const loadUnreadCounts = (): void => {
|
||||||
try {
|
// try {
|
||||||
const keys = Object.keys(localStorage).filter(key =>
|
// // Load unread counts for all peers we have messages for
|
||||||
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
|
// 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)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
console.log('Loading unread counts from localStorage. Found keys:', keys)
|
// 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)
|
||||||
|
// }
|
||||||
|
|
||||||
for (const key of keys) {
|
// Clear all unread counts
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize unread counts on startup
|
|
||||||
loadUnreadCounts()
|
|
||||||
|
|
||||||
// Clear all unread counts (for testing)
|
|
||||||
const clearAllUnreadCounts = (): void => {
|
const clearAllUnreadCounts = (): void => {
|
||||||
unreadCounts.value.clear()
|
unreadCounts.value.clear()
|
||||||
unreadCounts.value = new Map(unreadCounts.value)
|
|
||||||
|
|
||||||
// Also clear from localStorage
|
// Clear from localStorage for all peers
|
||||||
try {
|
for (const [peerPubkey] of messages.value) {
|
||||||
const keys = Object.keys(localStorage).filter(key =>
|
const unreadData = getUnreadData(peerPubkey)
|
||||||
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
|
unreadData.unreadCount = 0
|
||||||
)
|
saveUnreadData(peerPubkey, unreadData)
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
localStorage.removeItem(key)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to clear unread counts from localStorage:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear processed message IDs for a specific peer (useful for debugging)
|
// Clear processed message IDs for a peer
|
||||||
const clearProcessedMessageIds = (peerPubkey: string): void => {
|
const clearProcessedMessageIds = (peerPubkey: string): void => {
|
||||||
try {
|
|
||||||
const unreadData = getUnreadData(peerPubkey)
|
const unreadData = getUnreadData(peerPubkey)
|
||||||
const updatedData: UnreadMessageData = {
|
unreadData.processedMessageIds.clear()
|
||||||
...unreadData,
|
saveUnreadData(peerPubkey, 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug function to show current state of unread data for a peer
|
// Debug unread data for a peer
|
||||||
const debugUnreadData = (peerPubkey: string): void => {
|
const debugUnreadData = (peerPubkey: string): void => {
|
||||||
try {
|
|
||||||
const unreadData = getUnreadData(peerPubkey)
|
const unreadData = getUnreadData(peerPubkey)
|
||||||
console.log(`Debug unread data for ${peerPubkey}:`, {
|
console.log(`Unread data for ${peerPubkey}:`, unreadData)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get relays from config - requires VITE_NOSTR_RELAYS to be set
|
// Get relay configuration
|
||||||
const getRelays = (): NostrRelayConfig[] => {
|
const getRelays = (): NostrRelayConfig[] => {
|
||||||
const configuredRelays = config.nostr.relays
|
return config.nostr.relays.map(url => ({
|
||||||
if (!configuredRelays || configuredRelays.length === 0) {
|
url,
|
||||||
throw new Error('VITE_NOSTR_RELAYS environment variable must be configured for chat functionality')
|
read: true,
|
||||||
|
write: true
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return configuredRelays.map((url: string) => ({ url, read: true, write: true }))
|
// Connect using the relay hub
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
try {
|
try {
|
||||||
// Get current user from LNBits
|
// The relay hub should already be initialized by the app
|
||||||
await loadCurrentUser()
|
if (!relayHub.isConnected.value) {
|
||||||
|
await relayHub.connect()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize pool
|
console.log('Connected to relays via RelayHub')
|
||||||
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`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to connect:', error)
|
console.error('Failed to connect to relays:', 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)
|
|
||||||
throw 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
|
// Subscribe to messages from a specific peer
|
||||||
const subscribeToPeer = async (peerPubkey: string) => {
|
const subscribeToPeer = async (peerPubkey: string) => {
|
||||||
if (!currentUser.value) {
|
if (!currentUser.value) {
|
||||||
|
|
@ -364,17 +282,12 @@ export function useNostrChat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a pool and are connected
|
// Check if we have a pool and are connected
|
||||||
if (!pool.value) {
|
if (!relayHub.isConnected.value) {
|
||||||
console.warn('No pool available - initializing...')
|
|
||||||
initializePool()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isConnected.value) {
|
|
||||||
console.warn('Not connected to relays - attempting to connect...')
|
console.warn('Not connected to relays - attempting to connect...')
|
||||||
await connect()
|
await connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pool.value) {
|
if (!relayHub.isConnected.value) {
|
||||||
throw new Error('Failed to initialize Nostr pool')
|
throw new Error('Failed to initialize Nostr pool')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,9 +311,9 @@ export function useNostrChat() {
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const sub = pool.value.subscribeMany(
|
const unsubscribe = relayHub.subscribe({
|
||||||
relayConfigs.map(r => r.url),
|
id: `peer-${peerPubkey}-${Date.now()}`,
|
||||||
[
|
filters: [
|
||||||
{
|
{
|
||||||
kinds: [4],
|
kinds: [4],
|
||||||
authors: [peerPubkey],
|
authors: [peerPubkey],
|
||||||
|
|
@ -412,18 +325,17 @@ export function useNostrChat() {
|
||||||
'#p': [peerPubkey]
|
'#p': [peerPubkey]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
{
|
relays: relayConfigs.map(r => r.url),
|
||||||
onevent(event) {
|
onEvent: (event: any) => {
|
||||||
console.log('Received live event:', event.id, 'author:', event.pubkey)
|
console.log('Received live event:', event.id, 'author:', event.pubkey)
|
||||||
handleIncomingMessage(event, peerPubkey)
|
handleIncomingMessage(event, peerPubkey)
|
||||||
},
|
},
|
||||||
oneose() {
|
onEose: () => {
|
||||||
console.log('Subscription closed for peer:', peerPubkey)
|
console.log('Subscription closed for peer:', peerPubkey)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
return sub
|
return unsubscribe
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to a peer for notifications only (without loading full message history)
|
// 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
|
// Check if we have a pool and are connected
|
||||||
if (!pool.value) {
|
if (!relayHub.isConnected.value) {
|
||||||
console.warn('No pool available - initializing...')
|
|
||||||
initializePool()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isConnected.value) {
|
|
||||||
console.warn('Not connected to relays - attempting to connect...')
|
console.warn('Not connected to relays - attempting to connect...')
|
||||||
await connect()
|
await connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pool.value) {
|
if (!relayHub.isConnected.value) {
|
||||||
throw new Error('Failed to initialize Nostr pool')
|
throw new Error('Failed to initialize Nostr pool')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,11 +381,11 @@ export function useNostrChat() {
|
||||||
|
|
||||||
console.log('Notification subscription filters:', JSON.stringify(filters, null, 2))
|
console.log('Notification subscription filters:', JSON.stringify(filters, null, 2))
|
||||||
|
|
||||||
const sub = pool.value.subscribeMany(
|
const unsubscribe = relayHub.subscribe({
|
||||||
relayConfigs.map(r => r.url),
|
id: `notifications-${peerPubkey}-${Date.now()}`,
|
||||||
filters,
|
filters,
|
||||||
{
|
relays: relayConfigs.map(r => r.url),
|
||||||
onevent(event) {
|
onEvent: (event: any) => {
|
||||||
console.log('Received notification event:', {
|
console.log('Received notification event:', {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
author: event.pubkey,
|
author: event.pubkey,
|
||||||
|
|
@ -488,15 +395,14 @@ export function useNostrChat() {
|
||||||
})
|
})
|
||||||
handleIncomingMessage(event, peerPubkey)
|
handleIncomingMessage(event, peerPubkey)
|
||||||
},
|
},
|
||||||
oneose() {
|
onEose: () => {
|
||||||
console.log('Notification subscription closed for peer:', peerPubkey)
|
console.log('Notification subscription closed for peer:', peerPubkey)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Successfully created notification subscription for peer:', peerPubkey)
|
console.log('Successfully created notification subscription for peer:', peerPubkey)
|
||||||
console.log('=== SUBSCRIBE TO PEER FOR NOTIFICATIONS END ===')
|
console.log('=== SUBSCRIBE TO PEER FOR NOTIFICATIONS END ===')
|
||||||
return sub
|
return unsubscribe
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load historical messages for a peer
|
// Load historical messages for a peer
|
||||||
|
|
@ -522,11 +428,11 @@ export function useNostrChat() {
|
||||||
|
|
||||||
console.log('Historical query filters:', filters)
|
console.log('Historical query filters:', filters)
|
||||||
|
|
||||||
const historicalSub = pool.value!.subscribeMany(
|
const unsubscribe = relayHub.subscribe({
|
||||||
relayConfigs.map(r => r.url),
|
id: `historical-${peerPubkey}-${Date.now()}`,
|
||||||
filters,
|
filters,
|
||||||
{
|
relays: relayConfigs.map(r => r.url),
|
||||||
onevent(event) {
|
onEvent: (event: any) => {
|
||||||
console.log('Received historical event:', {
|
console.log('Received historical event:', {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
author: event.pubkey,
|
author: event.pubkey,
|
||||||
|
|
@ -535,15 +441,14 @@ export function useNostrChat() {
|
||||||
})
|
})
|
||||||
handleIncomingMessage(event, peerPubkey)
|
handleIncomingMessage(event, peerPubkey)
|
||||||
},
|
},
|
||||||
oneose() {
|
onEose: () => {
|
||||||
console.log('Historical query completed for peer:', peerPubkey)
|
console.log('Historical query completed for peer:', peerPubkey)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
// Wait a bit for historical messages to load
|
// Wait a bit for historical messages to load
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||||
historicalSub.close()
|
unsubscribe()
|
||||||
console.log('Historical query closed for peer:', peerPubkey)
|
console.log('Historical query closed for peer:', peerPubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -683,17 +588,12 @@ export function useNostrChat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a pool and are connected
|
// Check if we have a pool and are connected
|
||||||
if (!pool.value) {
|
if (!relayHub.isConnected.value) {
|
||||||
console.warn('No pool available - initializing...')
|
|
||||||
initializePool()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isConnected.value) {
|
|
||||||
console.warn('Not connected to relays - attempting to connect...')
|
console.warn('Not connected to relays - attempting to connect...')
|
||||||
await connect()
|
await connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pool.value) {
|
if (!relayHub.isConnected.value) {
|
||||||
throw new Error('Failed to initialize Nostr pool')
|
throw new Error('Failed to initialize Nostr pool')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -754,13 +654,8 @@ export function useNostrChat() {
|
||||||
// Finalize the event (sign it)
|
// Finalize the event (sign it)
|
||||||
const event = finalizeEvent(eventTemplate, hexToBytes(privateKey))
|
const event = finalizeEvent(eventTemplate, hexToBytes(privateKey))
|
||||||
|
|
||||||
// Publish to relays
|
// Publish to relays using the relay hub
|
||||||
const relayConfigs = getRelays()
|
await relayHub.publishEvent(event)
|
||||||
const publishPromises = relayConfigs.map(relay => {
|
|
||||||
return pool.value!.publish([relay.url], event)
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all(publishPromises)
|
|
||||||
|
|
||||||
// Add message to local state
|
// Add message to local state
|
||||||
const message: ChatMessage = {
|
const message: ChatMessage = {
|
||||||
|
|
@ -857,12 +752,12 @@ export function useNostrChat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for connection to be established
|
// 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')
|
console.log('Waiting for connection to be established before subscribing to peers')
|
||||||
// Wait a bit for connection to establish
|
// Wait a bit for connection to establish
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
if (!isConnected.value) {
|
if (!relayHub.isConnected.value) {
|
||||||
console.warn('Still not connected, skipping peer subscriptions')
|
console.warn('Still not connected, skipping peer subscriptions')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -890,7 +785,7 @@ export function useNostrChat() {
|
||||||
peers: readonly(peers),
|
peers: readonly(peers),
|
||||||
|
|
||||||
// Reactive computed properties
|
// Reactive computed properties
|
||||||
totalUnreadCount: readonly(totalUnreadCount),
|
totalUnreadCount: computed(() => getTotalUnreadCount()),
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
connect,
|
connect,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue'
|
||||||
import type { NostrNote } from '@/lib/nostr/client'
|
import type { NostrNote } from '@/lib/nostr/client'
|
||||||
import { useNostr } from '@/composables/useNostr'
|
import { useRelayHub } from '@/composables/useRelayHub'
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
import { useNostrStore } from '@/stores/nostr'
|
||||||
import { config as globalConfig } from '@/lib/config'
|
import { config as globalConfig } from '@/lib/config'
|
||||||
import { notificationManager } from '@/lib/notifications/manager'
|
import { notificationManager } from '@/lib/notifications/manager'
|
||||||
|
|
@ -13,7 +13,7 @@ export interface NostrFeedConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNostrFeed(config: NostrFeedConfig = {}) {
|
export function useNostrFeed(config: NostrFeedConfig = {}) {
|
||||||
const { getClient } = useNostr(config.relays ? { relays: config.relays } : undefined)
|
const relayHub = useRelayHub()
|
||||||
const nostrStore = useNostrStore()
|
const nostrStore = useNostrStore()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
@ -71,17 +71,16 @@ export function useNostrFeed(config: NostrFeedConfig = {}) {
|
||||||
|
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
// Connect to Nostr
|
// Connect to Nostr using the centralized relay hub
|
||||||
const client = getClient()
|
await relayHub.connect()
|
||||||
await client.connect()
|
isConnected.value = relayHub.isConnected.value
|
||||||
isConnected.value = client.isConnected
|
|
||||||
|
|
||||||
if (!isConnected.value) {
|
if (!isConnected.value) {
|
||||||
throw new Error('Failed to connect to Nostr relays')
|
throw new Error('Failed to connect to Nostr relays')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure fetch options based on feed type
|
// Configure fetch options based on feed type
|
||||||
const fetchOptions: Parameters<typeof client.fetchNotes>[0] = {
|
const fetchOptions: any = {
|
||||||
limit: config.limit || 50,
|
limit: config.limit || 50,
|
||||||
includeReplies: config.includeReplies || false
|
includeReplies: config.includeReplies || false
|
||||||
}
|
}
|
||||||
|
|
@ -96,8 +95,14 @@ export function useNostrFeed(config: NostrFeedConfig = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch new notes
|
// Fetch new notes using the relay hub
|
||||||
const newNotes = await client.fetchNotes(fetchOptions)
|
const newNotes = await relayHub.queryEvents([
|
||||||
|
{
|
||||||
|
kinds: [1], // TEXT_NOTE
|
||||||
|
limit: fetchOptions.limit,
|
||||||
|
authors: fetchOptions.authors
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
// Client-side filtering for 'general' feed (exclude admin posts)
|
// Client-side filtering for 'general' feed (exclude admin posts)
|
||||||
let filteredNotes = newNotes
|
let filteredNotes = newNotes
|
||||||
|
|
@ -147,17 +152,18 @@ export function useNostrFeed(config: NostrFeedConfig = {}) {
|
||||||
|
|
||||||
const subscribeToFeedUpdates = () => {
|
const subscribeToFeedUpdates = () => {
|
||||||
try {
|
try {
|
||||||
const client = getClient()
|
// Subscribe to real-time notes using the relay hub
|
||||||
|
unsubscribe = relayHub.subscribe({
|
||||||
// Subscribe to real-time notes
|
id: `feed-${config.feedType || 'all'}`,
|
||||||
unsubscribe = client.subscribeToNotes((newNote) => {
|
filters: [{ kinds: [1] }], // TEXT_NOTE
|
||||||
|
onEvent: (event: any) => {
|
||||||
// Only process notes newer than last seen
|
// Only process notes newer than last seen
|
||||||
if (newNote.created_at > lastSeenTimestamp) {
|
if (event.created_at > lastSeenTimestamp) {
|
||||||
// Check if note should be included based on feed type
|
// Check if note should be included based on feed type
|
||||||
const shouldInclude = shouldIncludeNote(newNote)
|
const shouldInclude = shouldIncludeNote(event)
|
||||||
if (shouldInclude) {
|
if (shouldInclude) {
|
||||||
// Add to beginning of notes array
|
// Add to beginning of notes array
|
||||||
notes.value.unshift(newNote)
|
notes.value.unshift(event)
|
||||||
|
|
||||||
// Limit the array size to prevent memory issues
|
// Limit the array size to prevent memory issues
|
||||||
if (notes.value.length > 100) {
|
if (notes.value.length > 100) {
|
||||||
|
|
@ -170,12 +176,13 @@ export function useNostrFeed(config: NostrFeedConfig = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notification if appropriate (only for admin announcements when not in announcements feed)
|
// Send notification if appropriate (only for admin announcements when not in announcements feed)
|
||||||
if (config.feedType !== 'announcements' && adminPubkeys.includes(newNote.pubkey)) {
|
if (config.feedType !== 'announcements' && adminPubkeys.includes(event.pubkey)) {
|
||||||
notificationManager.notifyForNote(newNote, nostrStore.account?.pubkey)
|
notificationManager.notifyForNote(event, nostrStore.account?.pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last seen timestamp
|
// Update last seen timestamp
|
||||||
lastSeenTimestamp = Math.max(lastSeenTimestamp, newNote.created_at)
|
lastSeenTimestamp = Math.max(lastSeenTimestamp, event.created_at)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -197,9 +204,8 @@ export function useNostrFeed(config: NostrFeedConfig = {}) {
|
||||||
const connectToFeed = async () => {
|
const connectToFeed = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Connecting to Nostr feed...')
|
console.log('Connecting to Nostr feed...')
|
||||||
const client = getClient()
|
await relayHub.connect()
|
||||||
await client.connect()
|
isConnected.value = relayHub.isConnected.value
|
||||||
isConnected.value = client.isConnected
|
|
||||||
console.log('Connected to Nostr feed')
|
console.log('Connected to Nostr feed')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error connecting to feed:', err)
|
console.error('Error connecting to feed:', err)
|
||||||
|
|
|
||||||
267
src/composables/useRelayHub.ts
Normal file
267
src/composables/useRelayHub.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,10 @@ import type { NostrNote } from '@/lib/nostr/client'
|
||||||
import { createTextNote, createReaction, createProfileMetadata } from '@/lib/nostr/events'
|
import { createTextNote, createReaction, createProfileMetadata } from '@/lib/nostr/events'
|
||||||
import { identity } from '@/composables/useIdentity'
|
import { identity } from '@/composables/useIdentity'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
import { useRelayHub } from './useRelayHub'
|
||||||
|
|
||||||
import { useNostr } from './useNostr'
|
export function useSocial() {
|
||||||
|
const relayHub = useRelayHub()
|
||||||
export function useSocial(relayUrls?: string[]) {
|
|
||||||
const { getClient } = useNostr(relayUrls ? { relays: relayUrls } : undefined)
|
|
||||||
const client = getClient()
|
|
||||||
const isPublishing = ref(false)
|
const isPublishing = ref(false)
|
||||||
const profiles = ref(new Map<string, any>())
|
const profiles = ref(new Map<string, any>())
|
||||||
|
|
||||||
|
|
@ -23,9 +21,9 @@ export function useSocial(relayUrls?: string[]) {
|
||||||
try {
|
try {
|
||||||
isPublishing.value = true
|
isPublishing.value = true
|
||||||
|
|
||||||
await client.connect()
|
await relayHub.connect()
|
||||||
const event = createTextNote(content, identity.currentIdentity.value, replyTo)
|
const event = createTextNote(content, identity.currentIdentity.value, replyTo)
|
||||||
await client.publishEvent(event)
|
await relayHub.publishEvent(event)
|
||||||
|
|
||||||
toast.success(replyTo ? 'Reply published!' : 'Note published!')
|
toast.success(replyTo ? 'Reply published!' : 'Note published!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -46,9 +44,9 @@ export function useSocial(relayUrls?: string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect()
|
await relayHub.connect()
|
||||||
const event = createReaction(targetEventId, targetAuthor, reaction, identity.currentIdentity.value)
|
const event = createReaction(targetEventId, targetAuthor, reaction, identity.currentIdentity.value)
|
||||||
await client.publishEvent(event)
|
await relayHub.publishEvent(event)
|
||||||
|
|
||||||
toast.success('Reaction added!')
|
toast.success('Reaction added!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -69,9 +67,9 @@ export function useSocial(relayUrls?: string[]) {
|
||||||
try {
|
try {
|
||||||
isPublishing.value = true
|
isPublishing.value = true
|
||||||
|
|
||||||
await client.connect()
|
await relayHub.connect()
|
||||||
const event = createProfileMetadata(profileData, identity.currentIdentity.value)
|
const event = createProfileMetadata(profileData, identity.currentIdentity.value)
|
||||||
await client.publishEvent(event)
|
await relayHub.publishEvent(event)
|
||||||
|
|
||||||
toast.success('Profile updated on Nostr!')
|
toast.success('Profile updated on Nostr!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -88,8 +86,13 @@ export function useSocial(relayUrls?: string[]) {
|
||||||
*/
|
*/
|
||||||
async function fetchReplies(noteId: string): Promise<NostrNote[]> {
|
async function fetchReplies(noteId: string): Promise<NostrNote[]> {
|
||||||
try {
|
try {
|
||||||
await client.connect()
|
await relayHub.connect()
|
||||||
return await client.fetchReplies(noteId)
|
return await relayHub.queryEvents([
|
||||||
|
{
|
||||||
|
kinds: [1], // TEXT_NOTE
|
||||||
|
'#e': [noteId] // Reply to specific event
|
||||||
|
}
|
||||||
|
])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch replies:', error)
|
console.error('Failed to fetch replies:', error)
|
||||||
throw error
|
throw error
|
||||||
|
|
@ -106,12 +109,22 @@ export function useSocial(relayUrls?: string[]) {
|
||||||
if (uncachedPubkeys.length === 0) return
|
if (uncachedPubkeys.length === 0) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect()
|
await relayHub.connect()
|
||||||
const fetchedProfiles = await client.fetchProfiles(uncachedPubkeys)
|
const fetchedProfiles = await relayHub.queryEvents([
|
||||||
|
{
|
||||||
|
kinds: [0], // PROFILE_METADATA
|
||||||
|
authors: uncachedPubkeys
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
// Update cache
|
// Update cache - convert events to profile map
|
||||||
fetchedProfiles.forEach((profile, pubkey) => {
|
fetchedProfiles.forEach((event) => {
|
||||||
profiles.value.set(pubkey, profile)
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch profiles:', error)
|
console.error('Failed to fetch profiles:', error)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { SimplePool, type Filter, type Event } from 'nostr-tools'
|
import { type Filter, type Event } from 'nostr-tools'
|
||||||
import { getReplyInfo, EventKinds } from './events'
|
import { getReplyInfo, EventKinds } from './events'
|
||||||
|
import { relayHub } from './relayHub'
|
||||||
|
|
||||||
export interface NostrClientConfig {
|
export interface NostrClientConfig {
|
||||||
relays: string[]
|
relays: string[]
|
||||||
|
|
@ -16,45 +17,40 @@ export interface NostrNote extends Event {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NostrClient {
|
export class NostrClient {
|
||||||
private pool: SimplePool
|
|
||||||
private relays: string[]
|
private relays: string[]
|
||||||
private _isConnected: boolean = false
|
// private _isConnected: boolean = false
|
||||||
|
|
||||||
constructor(config: NostrClientConfig) {
|
constructor(config: NostrClientConfig) {
|
||||||
this.pool = new SimplePool()
|
|
||||||
this.relays = config.relays
|
this.relays = config.relays
|
||||||
}
|
}
|
||||||
|
|
||||||
get isConnected(): boolean {
|
get isConnected(): boolean {
|
||||||
return this._isConnected
|
return relayHub.isConnected
|
||||||
}
|
|
||||||
|
|
||||||
get poolInstance(): SimplePool {
|
|
||||||
return this.pool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Try to connect to at least one relay
|
// The relay hub should already be initialized by the time this is called
|
||||||
const connections = await Promise.allSettled(
|
if (!relayHub.isInitialized) {
|
||||||
this.relays.map(relay => this.pool.ensureRelay(relay))
|
throw new Error('RelayHub not initialized. Please ensure the app has initialized the relay hub first.')
|
||||||
)
|
|
||||||
|
|
||||||
// Check if at least one connection was successful
|
|
||||||
this._isConnected = connections.some(result => result.status === 'fulfilled')
|
|
||||||
|
|
||||||
if (!this._isConnected) {
|
|
||||||
throw new Error('Failed to connect to any relay')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we're already connected
|
||||||
|
if (relayHub.isConnected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to connect using the relay hub
|
||||||
|
await relayHub.connect()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._isConnected = false
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.pool.close(this.relays)
|
// Note: We don't disconnect the relay hub here as other components might be using it
|
||||||
this._isConnected = false
|
// The relay hub will be managed at the app level
|
||||||
|
console.log('Client disconnected (relay hub remains active)')
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchNotes(options: {
|
async fetchNotes(options: {
|
||||||
|
|
@ -78,23 +74,8 @@ export class NostrClient {
|
||||||
]
|
]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use proper subscription method to get multiple events
|
// Use the relay hub to query events
|
||||||
const allEvents: Event[] = []
|
const allEvents = await relayHub.queryEvents(filters, this.relays)
|
||||||
|
|
||||||
const subscription = this.pool.subscribeMany(this.relays, filters, {
|
|
||||||
onevent(event: Event) {
|
|
||||||
allEvents.push(event)
|
|
||||||
},
|
|
||||||
oneose() {
|
|
||||||
// End of stored events - subscription is complete for initial fetch
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for events to be collected (give it time to get all stored events)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
||||||
|
|
||||||
// Close the subscription since we just want the initial fetch
|
|
||||||
subscription.close()
|
|
||||||
|
|
||||||
const noteEvents = [allEvents] // Wrap in array to match expected format
|
const noteEvents = [allEvents] // Wrap in array to match expected format
|
||||||
|
|
||||||
|
|
@ -139,21 +120,13 @@ export class NostrClient {
|
||||||
* Publish an event to all connected relays
|
* Publish an event to all connected relays
|
||||||
*/
|
*/
|
||||||
async publishEvent(event: Event): Promise<void> {
|
async publishEvent(event: Event): Promise<void> {
|
||||||
if (!this._isConnected) {
|
if (!relayHub.isConnected) {
|
||||||
throw new Error('Not connected to any relays')
|
throw new Error('Not connected to any relays')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled(
|
const result = await relayHub.publishEvent(event)
|
||||||
this.relays.map(relay => this.pool.publish([relay], event))
|
console.log(`Published event ${event.id} to ${result.success}/${result.total} relays`)
|
||||||
)
|
|
||||||
|
|
||||||
const failures = results.filter(result => result.status === 'rejected')
|
|
||||||
if (failures.length === results.length) {
|
|
||||||
throw new Error('Failed to publish to any relay')
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Published event ${event.id} to ${results.length - failures.length}/${results.length} relays`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to publish event:', error)
|
console.error('Failed to publish event:', error)
|
||||||
throw error
|
throw error
|
||||||
|
|
@ -171,21 +144,12 @@ export class NostrClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const events = await Promise.all(
|
const events = await relayHub.queryEvents([filter], this.relays)
|
||||||
this.relays.map(async (relay) => {
|
|
||||||
try {
|
|
||||||
return await this.pool.querySync([relay], filter)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to fetch replies from relay ${relay}:`, error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Flatten and deduplicate events by ID
|
// Flatten and deduplicate events by ID
|
||||||
const uniqueEvents = Array.from(
|
const uniqueEvents = Array.from(
|
||||||
new Map(
|
new Map(
|
||||||
events.flat().map(event => [event.id, event])
|
events.map(event => [event.id, event])
|
||||||
).values()
|
).values()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -255,49 +219,12 @@ export class NostrClient {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Fetching events with filters:', JSON.stringify(filters, null, 2))
|
console.log('Fetching events with filters:', JSON.stringify(filters, null, 2))
|
||||||
const allEvents: Event[] = []
|
|
||||||
|
|
||||||
// Try each relay individually to identify problematic relays
|
const events = await relayHub.queryEvents(filters, this.relays)
|
||||||
const relayResults = await Promise.allSettled(
|
|
||||||
this.relays.map(async (relay) => {
|
|
||||||
try {
|
|
||||||
console.log(`Fetching from relay: ${relay}`)
|
|
||||||
const relayEvents: Event[] = []
|
|
||||||
const subscription = this.pool.subscribeMany([relay], filters, {
|
|
||||||
onevent(event: Event) {
|
|
||||||
console.log(`Received event from ${relay}:`, event)
|
|
||||||
relayEvents.push(event)
|
|
||||||
},
|
|
||||||
oneose() {
|
|
||||||
console.log(`End of stored events from ${relay}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for events to be collected
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
||||||
subscription.close()
|
|
||||||
|
|
||||||
console.log(`Found ${relayEvents.length} events from ${relay}`)
|
|
||||||
return relayEvents
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to fetch from relay ${relay}:`, error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Collect all successful results
|
|
||||||
relayResults.forEach((result, index) => {
|
|
||||||
if (result.status === 'fulfilled') {
|
|
||||||
allEvents.push(...result.value)
|
|
||||||
} else {
|
|
||||||
console.warn(`Relay ${this.relays[index]} failed:`, result.reason)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Deduplicate events by ID
|
// Deduplicate events by ID
|
||||||
const uniqueEvents = Array.from(
|
const uniqueEvents = Array.from(
|
||||||
new Map(allEvents.map(event => [event.id, event])).values()
|
new Map(events.map(event => [event.id, event])).values()
|
||||||
)
|
)
|
||||||
|
|
||||||
return uniqueEvents
|
return uniqueEvents
|
||||||
|
|
@ -321,23 +248,13 @@ export class NostrClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const events = await Promise.all(
|
const events = await relayHub.queryEvents([filter], this.relays)
|
||||||
this.relays.map(async (relay) => {
|
|
||||||
try {
|
|
||||||
return await this.pool.querySync([relay], filter)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to fetch profiles from relay ${relay}:`, error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const profiles = new Map<string, any>()
|
const profiles = new Map<string, any>()
|
||||||
const allEvents = events.flat()
|
|
||||||
|
|
||||||
// Get the latest profile for each pubkey
|
// Get the latest profile for each pubkey
|
||||||
pubkeys.forEach(pubkey => {
|
pubkeys.forEach(pubkey => {
|
||||||
const userEvents = allEvents
|
const userEvents = events
|
||||||
.filter(event => event.pubkey === pubkey)
|
.filter(event => event.pubkey === pubkey)
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
|
|
@ -365,13 +282,12 @@ export class NostrClient {
|
||||||
since: Math.floor(Date.now() / 1000)
|
since: Math.floor(Date.now() / 1000)
|
||||||
}]
|
}]
|
||||||
|
|
||||||
// Subscribe to each relay individually
|
// Use the relay hub to subscribe
|
||||||
const unsubscribes = this.relays.map(relay => {
|
const unsubscribe = relayHub.subscribe({
|
||||||
const sub = this.pool.subscribeMany(
|
id: `notes-subscription-${Date.now()}`,
|
||||||
[relay],
|
|
||||||
filters,
|
filters,
|
||||||
{
|
relays: this.relays,
|
||||||
onevent: (event: Event) => {
|
onEvent: (event: Event) => {
|
||||||
const replyInfo = getReplyInfo(event)
|
const replyInfo = getReplyInfo(event)
|
||||||
onNote({
|
onNote({
|
||||||
...event,
|
...event,
|
||||||
|
|
@ -383,14 +299,8 @@ export class NostrClient {
|
||||||
mentions: replyInfo.mentions
|
mentions: replyInfo.mentions
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
return () => sub.close()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return a function that unsubscribes from all relays
|
return unsubscribe
|
||||||
return () => {
|
|
||||||
unsubscribes.forEach(unsub => unsub())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
480
src/lib/nostr/relayHub.ts
Normal file
480
src/lib/nostr/relayHub.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools'
|
||||||
|
|
||||||
|
// Simple EventEmitter implementation for browser compatibility
|
||||||
|
class EventEmitter {
|
||||||
|
private events: { [key: string]: Function[] } = {}
|
||||||
|
|
||||||
|
on(event: string, listener: Function): void {
|
||||||
|
if (!this.events[event]) {
|
||||||
|
this.events[event] = []
|
||||||
|
}
|
||||||
|
this.events[event].push(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, listener: Function): void {
|
||||||
|
if (!this.events[event]) return
|
||||||
|
const index = this.events[event].indexOf(listener)
|
||||||
|
if (index > -1) {
|
||||||
|
this.events[event].splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: any[]): void {
|
||||||
|
if (!this.events[event]) return
|
||||||
|
this.events[event].forEach(listener => listener(...args))
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners(event?: string): void {
|
||||||
|
if (event) {
|
||||||
|
delete this.events[event]
|
||||||
|
} else {
|
||||||
|
this.events = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayConfig {
|
||||||
|
url: string
|
||||||
|
read: boolean
|
||||||
|
write: boolean
|
||||||
|
priority?: number // Lower number = higher priority
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionConfig {
|
||||||
|
id: string
|
||||||
|
filters: Filter[]
|
||||||
|
relays?: string[] // If not specified, uses all connected relays
|
||||||
|
onEvent?: (event: Event) => void
|
||||||
|
onEose?: () => void
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayStatus {
|
||||||
|
url: string
|
||||||
|
connected: boolean
|
||||||
|
lastSeen: number
|
||||||
|
error?: string
|
||||||
|
latency?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayHub extends EventEmitter {
|
||||||
|
private pool: SimplePool
|
||||||
|
private relayConfigs: Map<string, RelayConfig> = new Map()
|
||||||
|
private connectedRelays: Map<string, Relay> = new Map()
|
||||||
|
private subscriptions: Map<string, any> = new Map()
|
||||||
|
public isInitialized = false
|
||||||
|
private reconnectInterval?: NodeJS.Timeout
|
||||||
|
private healthCheckInterval?: NodeJS.Timeout
|
||||||
|
private mobileVisibilityHandler?: () => void
|
||||||
|
|
||||||
|
// Connection state
|
||||||
|
private _isConnected = false
|
||||||
|
private _connectionAttempts = 0
|
||||||
|
private readonly maxReconnectAttempts = 5
|
||||||
|
private readonly reconnectDelay = 5000 // 5 seconds
|
||||||
|
private readonly healthCheckIntervalMs = 30000 // 30 seconds
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.pool = new SimplePool()
|
||||||
|
this.setupMobileVisibilityHandling()
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this._isConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
get connectedRelayCount(): number {
|
||||||
|
return this.connectedRelays.size
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalRelayCount(): number {
|
||||||
|
return this.relayConfigs.size
|
||||||
|
}
|
||||||
|
|
||||||
|
get relayStatuses(): RelayStatus[] {
|
||||||
|
return Array.from(this.relayConfigs.values()).map(config => {
|
||||||
|
const relay = this.connectedRelays.get(config.url)
|
||||||
|
return {
|
||||||
|
url: config.url,
|
||||||
|
connected: !!relay,
|
||||||
|
lastSeen: relay ? Date.now() : 0,
|
||||||
|
error: relay ? undefined : 'Not connected',
|
||||||
|
latency: relay ? 0 : undefined // TODO: Implement actual latency measurement
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the relay hub with relay configurations
|
||||||
|
*/
|
||||||
|
async initialize(relayUrls: string[]): Promise<void> {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
console.warn('RelayHub already initialized')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert URLs to relay configs
|
||||||
|
this.relayConfigs.clear()
|
||||||
|
relayUrls.forEach((url, index) => {
|
||||||
|
this.relayConfigs.set(url, {
|
||||||
|
url,
|
||||||
|
read: true,
|
||||||
|
write: true,
|
||||||
|
priority: index
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start connection management
|
||||||
|
await this.connect()
|
||||||
|
this.startHealthCheck()
|
||||||
|
this.isInitialized = true
|
||||||
|
|
||||||
|
console.log(`RelayHub initialized with ${relayUrls.length} relays`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to all configured relays
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.relayConfigs.size === 0) {
|
||||||
|
throw new Error('No relay configurations found. Call initialize() first.')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._connectionAttempts++
|
||||||
|
console.log(`Connecting to ${this.relayConfigs.size} relays (attempt ${this._connectionAttempts})`)
|
||||||
|
|
||||||
|
// Connect to relays in priority order
|
||||||
|
const sortedRelays = Array.from(this.relayConfigs.values())
|
||||||
|
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
|
||||||
|
|
||||||
|
const connectionPromises = sortedRelays.map(async (config) => {
|
||||||
|
try {
|
||||||
|
const relay = await this.pool.ensureRelay(config.url)
|
||||||
|
this.connectedRelays.set(config.url, relay)
|
||||||
|
console.log(`Connected to relay: ${config.url}`)
|
||||||
|
return { url: config.url, success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to connect to relay ${config.url}:`, error)
|
||||||
|
return { url: config.url, success: false, error }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(connectionPromises)
|
||||||
|
const successfulConnections = results.filter(
|
||||||
|
result => result.status === 'fulfilled' && result.value.success
|
||||||
|
)
|
||||||
|
|
||||||
|
if (successfulConnections.length > 0) {
|
||||||
|
this._isConnected = true
|
||||||
|
this._connectionAttempts = 0
|
||||||
|
this.emit('connected', successfulConnections.length)
|
||||||
|
console.log(`Successfully connected to ${successfulConnections.length}/${this.relayConfigs.size} relays`)
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to connect to any relay')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this._isConnected = false
|
||||||
|
this.emit('connectionError', error)
|
||||||
|
console.error('Connection failed:', error)
|
||||||
|
|
||||||
|
// Schedule reconnection if we haven't exceeded max attempts
|
||||||
|
if (this._connectionAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.scheduleReconnect()
|
||||||
|
} else {
|
||||||
|
this.emit('maxReconnectAttemptsReached')
|
||||||
|
console.error('Max reconnection attempts reached')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from all relays
|
||||||
|
*/
|
||||||
|
disconnect(): void {
|
||||||
|
console.log('Disconnecting from all relays')
|
||||||
|
|
||||||
|
// Clear intervals
|
||||||
|
if (this.reconnectInterval) {
|
||||||
|
clearTimeout(this.reconnectInterval)
|
||||||
|
this.reconnectInterval = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.healthCheckInterval) {
|
||||||
|
clearInterval(this.healthCheckInterval)
|
||||||
|
this.healthCheckInterval = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all subscriptions
|
||||||
|
this.subscriptions.forEach(sub => sub.close())
|
||||||
|
this.subscriptions.clear()
|
||||||
|
|
||||||
|
// Close all relay connections
|
||||||
|
this.pool.close(Array.from(this.relayConfigs.keys()))
|
||||||
|
this.connectedRelays.clear()
|
||||||
|
|
||||||
|
this._isConnected = false
|
||||||
|
this.emit('disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to events from relays
|
||||||
|
*/
|
||||||
|
subscribe(config: SubscriptionConfig): () => void {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
throw new Error('RelayHub not initialized. Call initialize() first.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._isConnected) {
|
||||||
|
throw new Error('Not connected to any relays')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which relays to use
|
||||||
|
const targetRelays = config.relays || Array.from(this.connectedRelays.keys())
|
||||||
|
const availableRelays = targetRelays.filter(url => this.connectedRelays.has(url))
|
||||||
|
|
||||||
|
if (availableRelays.length === 0) {
|
||||||
|
throw new Error('No available relays for subscription')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Creating subscription ${config.id} on ${availableRelays.length} relays`)
|
||||||
|
|
||||||
|
// Create subscription using the pool
|
||||||
|
const subscription = this.pool.subscribeMany(availableRelays, config.filters, {
|
||||||
|
onevent: (event: Event) => {
|
||||||
|
config.onEvent?.(event)
|
||||||
|
this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' })
|
||||||
|
},
|
||||||
|
oneose: () => {
|
||||||
|
config.onEose?.()
|
||||||
|
this.emit('eose', { subscriptionId: config.id })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store subscription for cleanup
|
||||||
|
this.subscriptions.set(config.id, subscription)
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
this.unsubscribe(config.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from a specific subscription
|
||||||
|
*/
|
||||||
|
unsubscribe(subscriptionId: string): void {
|
||||||
|
const subscription = this.subscriptions.get(subscriptionId)
|
||||||
|
if (subscription) {
|
||||||
|
subscription.close()
|
||||||
|
this.subscriptions.delete(subscriptionId)
|
||||||
|
console.log(`Unsubscribed from ${subscriptionId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish an event to all connected relays
|
||||||
|
*/
|
||||||
|
async publishEvent(event: Event): Promise<{ success: number; total: number }> {
|
||||||
|
if (!this._isConnected) {
|
||||||
|
throw new Error('Not connected to any relays')
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayUrls = Array.from(this.connectedRelays.keys())
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
relayUrls.map(relay => this.pool.publish([relay], event))
|
||||||
|
)
|
||||||
|
|
||||||
|
const successful = results.filter(result => result.status === 'fulfilled').length
|
||||||
|
const total = results.length
|
||||||
|
|
||||||
|
console.log(`Published event ${event.id} to ${successful}/${total} relays`)
|
||||||
|
this.emit('eventPublished', { eventId: event.id, success: successful, total })
|
||||||
|
|
||||||
|
return { success: successful, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query events from relays (one-time fetch)
|
||||||
|
*/
|
||||||
|
async queryEvents(filters: Filter[], relays?: string[]): Promise<Event[]> {
|
||||||
|
if (!this._isConnected) {
|
||||||
|
throw new Error('Not connected to any relays')
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRelays = relays || Array.from(this.connectedRelays.keys())
|
||||||
|
const availableRelays = targetRelays.filter(url => this.connectedRelays.has(url))
|
||||||
|
|
||||||
|
if (availableRelays.length === 0) {
|
||||||
|
throw new Error('No available relays for query')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Query each filter separately and combine results
|
||||||
|
const allEvents: Event[] = []
|
||||||
|
for (const filter of filters) {
|
||||||
|
const events = await this.pool.querySync(availableRelays, filter)
|
||||||
|
allEvents.push(...events)
|
||||||
|
}
|
||||||
|
console.log(`Queried ${allEvents.length} events from ${availableRelays.length} relays`)
|
||||||
|
return allEvents
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Query failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific relay instance
|
||||||
|
*/
|
||||||
|
getRelay(url: string): Relay | undefined {
|
||||||
|
return this.connectedRelays.get(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific relay is connected
|
||||||
|
*/
|
||||||
|
isRelayConnected(url: string): boolean {
|
||||||
|
return this.connectedRelays.has(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force reconnection to all relays
|
||||||
|
*/
|
||||||
|
async reconnect(): Promise<void> {
|
||||||
|
console.log('Forcing reconnection to all relays')
|
||||||
|
this.disconnect()
|
||||||
|
await this.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule automatic reconnection
|
||||||
|
*/
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectInterval) {
|
||||||
|
clearTimeout(this.reconnectInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectInterval = setTimeout(async () => {
|
||||||
|
console.log('Attempting automatic reconnection...')
|
||||||
|
await this.connect()
|
||||||
|
}, this.reconnectDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start health check monitoring
|
||||||
|
*/
|
||||||
|
private startHealthCheck(): void {
|
||||||
|
if (this.healthCheckInterval) {
|
||||||
|
clearInterval(this.healthCheckInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.healthCheckInterval = setInterval(() => {
|
||||||
|
this.performHealthCheck()
|
||||||
|
}, this.healthCheckIntervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform health check on all relays
|
||||||
|
*/
|
||||||
|
private async performHealthCheck(): Promise<void> {
|
||||||
|
if (!this._isConnected) return
|
||||||
|
|
||||||
|
console.log('Performing relay health check...')
|
||||||
|
const disconnectedRelays: string[] = []
|
||||||
|
|
||||||
|
// Check each relay connection
|
||||||
|
for (const [url] of this.connectedRelays) {
|
||||||
|
try {
|
||||||
|
// Try to send a ping or check connection status
|
||||||
|
// For now, we'll just check if the relay is still in our connected relays map
|
||||||
|
if (!this.connectedRelays.has(url)) {
|
||||||
|
disconnectedRelays.push(url)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Health check failed for relay ${url}:`, error)
|
||||||
|
disconnectedRelays.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove disconnected relays
|
||||||
|
disconnectedRelays.forEach(url => {
|
||||||
|
this.connectedRelays.delete(url)
|
||||||
|
console.log(`Removed disconnected relay: ${url}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update connection status
|
||||||
|
if (this.connectedRelays.size === 0) {
|
||||||
|
this._isConnected = false
|
||||||
|
this.emit('allRelaysDisconnected')
|
||||||
|
console.warn('All relays disconnected, attempting reconnection...')
|
||||||
|
await this.connect()
|
||||||
|
} else if (this.connectedRelays.size < this.relayConfigs.size) {
|
||||||
|
this.emit('partialDisconnection', {
|
||||||
|
connected: this.connectedRelays.size,
|
||||||
|
total: this.relayConfigs.size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup mobile visibility handling for better WebSocket management
|
||||||
|
*/
|
||||||
|
private setupMobileVisibilityHandling(): void {
|
||||||
|
// Handle page visibility changes (mobile app backgrounding)
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
this.mobileVisibilityHandler = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
console.log('Page hidden, maintaining WebSocket connections')
|
||||||
|
// Keep connections alive but reduce activity
|
||||||
|
} else {
|
||||||
|
console.log('Page visible, resuming normal WebSocket activity')
|
||||||
|
// Resume normal activity and check connections
|
||||||
|
this.performHealthCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', this.mobileVisibilityHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle online/offline events
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
console.log('Network online, checking relay connections...')
|
||||||
|
this.performHealthCheck()
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
console.log('Network offline, marking as disconnected...')
|
||||||
|
this._isConnected = false
|
||||||
|
this.emit('networkOffline')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
console.log('Destroying RelayHub...')
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
if (this.mobileVisibilityHandler && typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', this.mobileVisibilityHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('online', () => {})
|
||||||
|
window.removeEventListener('offline', () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect and cleanup
|
||||||
|
this.disconnect()
|
||||||
|
this.removeAllListeners()
|
||||||
|
this.isInitialized = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const relayHub = new RelayHub()
|
||||||
271
src/pages/RelayHubDemo.vue
Normal file
271
src/pages/RelayHubDemo.vue
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-4">Nostr Relay Hub Demo</h1>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
This page demonstrates the centralized relay hub that manages all Nostr WebSocket connections.
|
||||||
|
The hub ensures connections stay active, especially important for mobile devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Relay Hub Status -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<RelayHubStatus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Test -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Connection Test</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
@click="testConnection"
|
||||||
|
:disabled="isTesting"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ isTesting ? 'Testing...' : 'Test Connection' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="testResult" class="text-sm" :class="testResult.success ? 'text-green-600' : 'text-red-600'">
|
||||||
|
{{ testResult.message }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="testResult && testResult.success" class="text-sm text-gray-600">
|
||||||
|
<p>Successfully connected to {{ testResult.connectedCount }} relays</p>
|
||||||
|
<p>Connection health: {{ testResult.health }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subscription Test -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Subscription Test</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
@click="testSubscription"
|
||||||
|
:disabled="!isConnected || isSubscribing"
|
||||||
|
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ isSubscribing ? 'Subscribing...' : 'Test Subscription' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="activeTestSubscription"
|
||||||
|
@click="unsubscribeTest"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Unsubscribe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="subscriptionEvents.length > 0" class="text-sm">
|
||||||
|
<p class="font-medium mb-2">Received Events ({{ subscriptionEvents.length }}):</p>
|
||||||
|
<div class="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="event in subscriptionEvents.slice(-5)"
|
||||||
|
:key="event.id"
|
||||||
|
class="p-2 bg-gray-50 rounded text-xs"
|
||||||
|
>
|
||||||
|
<div class="font-mono text-gray-700">{{ event.id.substring(0, 8) }}...</div>
|
||||||
|
<div class="text-gray-600">Kind: {{ event.kind }}</div>
|
||||||
|
<div class="text-gray-600">Author: {{ event.pubkey.substring(0, 8) }}...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Visibility Test -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Mobile Visibility Test</h2>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Test how the relay hub handles mobile app lifecycle events (visibility changes, network changes).
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="w-4 h-4 rounded-full" :class="pageVisible ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||||
|
<span>Page Visible: {{ pageVisible ? 'Yes' : 'No' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="w-4 h-4 rounded-full" :class="networkOnline ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||||
|
<span>Network Online: {{ networkOnline ? 'Yes' : 'No' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-xs text-gray-500">
|
||||||
|
<p>• Try switching tabs or minimizing the browser window</p>
|
||||||
|
<p>• Disconnect your network to test offline handling</p>
|
||||||
|
<p>• The relay hub will maintain connections and reconnect automatically</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Info -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Configuration</h2>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="font-medium">Configured Relays:</span>
|
||||||
|
<span>{{ config.nostr.relays.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="font-medium">Market Relays:</span>
|
||||||
|
<span>{{ config.market.supportedRelays.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<p class="font-medium mb-2">Relay URLs:</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="relay in config.nostr.relays"
|
||||||
|
:key="relay"
|
||||||
|
class="font-mono text-xs bg-gray-50 p-2 rounded"
|
||||||
|
>
|
||||||
|
{{ relay }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import RelayHubStatus from '@/components/RelayHubStatus.vue'
|
||||||
|
import { useRelayHub } from '@/composables/useRelayHub'
|
||||||
|
import { config } from '@/lib/config'
|
||||||
|
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
connectionStatus,
|
||||||
|
connectionHealth,
|
||||||
|
connectedRelayCount,
|
||||||
|
initialize,
|
||||||
|
connect,
|
||||||
|
subscribe
|
||||||
|
} = useRelayHub()
|
||||||
|
|
||||||
|
// Test state
|
||||||
|
const isTesting = ref(false)
|
||||||
|
const testResult = ref<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
connectedCount?: number
|
||||||
|
health?: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const isSubscribing = ref(false)
|
||||||
|
const activeTestSubscription = ref<(() => void) | null>(null)
|
||||||
|
const subscriptionEvents = ref<any[]>([])
|
||||||
|
|
||||||
|
// Mobile visibility state
|
||||||
|
const pageVisible = ref(!document.hidden)
|
||||||
|
const networkOnline = ref(navigator.onLine)
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
const testConnection = async () => {
|
||||||
|
try {
|
||||||
|
isTesting.value = true
|
||||||
|
testResult.value = null
|
||||||
|
|
||||||
|
if (!isConnected.value) {
|
||||||
|
await connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
testResult.value = {
|
||||||
|
success: true,
|
||||||
|
message: 'Connection test successful!',
|
||||||
|
connectedCount: connectedRelayCount.value,
|
||||||
|
health: connectionHealth.value
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
testResult.value = {
|
||||||
|
success: false,
|
||||||
|
message: `Connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isTesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test subscription
|
||||||
|
const testSubscription = async () => {
|
||||||
|
try {
|
||||||
|
isSubscribing.value = true
|
||||||
|
subscriptionEvents.value = []
|
||||||
|
|
||||||
|
// Subscribe to some test events (kind 1 text notes)
|
||||||
|
const unsubscribe = subscribe({
|
||||||
|
id: 'test-subscription',
|
||||||
|
filters: [{ kinds: [1], limit: 10 }],
|
||||||
|
onEvent: (event: any) => {
|
||||||
|
subscriptionEvents.value.push(event)
|
||||||
|
console.log('Received test event:', event)
|
||||||
|
},
|
||||||
|
onEose: () => {
|
||||||
|
console.log('Test subscription EOSE')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
activeTestSubscription.value = unsubscribe
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Subscription test failed:', error)
|
||||||
|
} finally {
|
||||||
|
isSubscribing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe from test
|
||||||
|
const unsubscribeTest = () => {
|
||||||
|
if (activeTestSubscription.value) {
|
||||||
|
activeTestSubscription.value()
|
||||||
|
activeTestSubscription.value = null
|
||||||
|
subscriptionEvents.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup mobile visibility handlers
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
pageVisible.value = !document.hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnline = () => {
|
||||||
|
networkOnline.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
networkOnline.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Initialize relay hub if not already done
|
||||||
|
if (!isConnected.value && connectionStatus.value === 'disconnected') {
|
||||||
|
initialize().catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup visibility and network listeners
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Cleanup listeners
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
|
||||||
|
// Cleanup test subscription
|
||||||
|
if (activeTestSubscription.value) {
|
||||||
|
activeTestSubscription.value()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -58,6 +58,15 @@ const router = createRouter({
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/relay-hub-demo',
|
||||||
|
name: 'relay-hub-demo',
|
||||||
|
component: () => import('@/pages/RelayHubDemo.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Relay Hub Demo',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { NostrClient } from '@/lib/nostr/client'
|
import { relayHub } from '@/lib/nostr/relayHub'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
import { pushService, type PushSubscriptionData } from '@/lib/notifications/push'
|
import { pushService, type PushSubscriptionData } from '@/lib/notifications/push'
|
||||||
|
|
||||||
|
|
@ -24,27 +24,17 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
const pushSubscription = ref<PushSubscriptionData | null>(null)
|
const pushSubscription = ref<PushSubscriptionData | null>(null)
|
||||||
const notificationsEnabled = ref(false)
|
const notificationsEnabled = ref(false)
|
||||||
|
|
||||||
// Singleton client instance
|
|
||||||
let client: NostrClient | null = null
|
|
||||||
|
|
||||||
// Get or create client instance
|
|
||||||
function getClient(): NostrClient {
|
|
||||||
if (!client) {
|
|
||||||
client = new NostrClient({ relays: relayUrls.value })
|
|
||||||
}
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection management
|
// Connection management
|
||||||
async function connect(): Promise<void> {
|
async function connect(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
error.value = null
|
error.value = null
|
||||||
isConnecting.value = true
|
isConnecting.value = true
|
||||||
|
|
||||||
const nostrClient = getClient()
|
// Initialize and connect using the centralized relay hub
|
||||||
await nostrClient.connect()
|
await relayHub.initialize(relayUrls.value)
|
||||||
|
await relayHub.connect()
|
||||||
|
|
||||||
isConnected.value = nostrClient.isConnected
|
isConnected.value = relayHub.isConnected
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err : new Error('Failed to connect')
|
error.value = err instanceof Error ? err : new Error('Failed to connect')
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
|
|
@ -55,10 +45,8 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnect(): void {
|
function disconnect(): void {
|
||||||
if (client) {
|
// Don't disconnect the relay hub as it's managed centrally
|
||||||
client.disconnect()
|
// Just update our local state
|
||||||
client = null
|
|
||||||
}
|
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
isConnecting.value = false
|
isConnecting.value = false
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
@ -71,11 +59,7 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
|
|
||||||
function setRelayUrls(urls: string[]) {
|
function setRelayUrls(urls: string[]) {
|
||||||
relayUrls.value = urls
|
relayUrls.value = urls
|
||||||
// Recreate client with new relays
|
// The relay hub will handle reconnection with new relays if needed
|
||||||
if (client) {
|
|
||||||
client.disconnect()
|
|
||||||
client = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAccount(nostrAccount: NostrAccount | null) {
|
function setAccount(nostrAccount: NostrAccount | null) {
|
||||||
|
|
@ -111,7 +95,6 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
localStorage.removeItem('notifications-enabled')
|
localStorage.removeItem('notifications-enabled')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to disable push notifications:', error)
|
console.error('Failed to disable push notifications:', error)
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,6 +137,33 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup relay hub event listeners to keep store state in sync
|
||||||
|
function setupRelayHubListeners(): void {
|
||||||
|
relayHub.on('connected', () => {
|
||||||
|
isConnected.value = true
|
||||||
|
isConnecting.value = false
|
||||||
|
error.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
relayHub.on('disconnected', () => {
|
||||||
|
isConnected.value = false
|
||||||
|
isConnecting.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
relayHub.on('error', (err: Error) => {
|
||||||
|
error.value = err
|
||||||
|
isConnected.value = false
|
||||||
|
isConnecting.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
relayHub.on('connecting', () => {
|
||||||
|
isConnecting.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize relay hub listeners
|
||||||
|
setupRelayHubListeners()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
isConnected,
|
isConnected,
|
||||||
|
|
@ -167,7 +177,6 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
// Actions
|
// Actions
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
getClient,
|
|
||||||
setConnected,
|
setConnected,
|
||||||
setRelayUrls,
|
setRelayUrls,
|
||||||
setAccount,
|
setAccount,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue