Refactor chat and market modules for improved integration and maintainability

- Remove deprecated Nostr chat and relay hub components, transitioning to a modular chat service for better encapsulation.
- Update App.vue and Navbar.vue to utilize the new chat module, enhancing user experience with automatic peer management.
- Simplify event handling and connection logic in ChatComponent.vue, ensuring compatibility with the new chat service architecture.
- Adjust market settings and order history components to reflect changes in the chat module, improving overall coherence in the application structure.
- Clean up unused imports and streamline configuration access for better performance and maintainability.
This commit is contained in:
padreug 2025-09-05 01:44:15 +02:00
parent 63de083909
commit 17c07c37a0
17 changed files with 63 additions and 2222 deletions

View file

@ -7,9 +7,7 @@ import LoginDialog from '@/components/auth/LoginDialog.vue'
import { Toaster } from '@/components/ui/sonner'
import 'vue-sonner/style.css'
import { useMarketPreloader } from '@/composables/useMarketPreloader'
import { nostrChat } from '@/composables/useNostrChat'
import { auth } from '@/composables/useAuth'
import { relayHubComposable } from '@/composables/useRelayHub'
import { toast } from 'vue-sonner'
const route = useRoute()
@ -18,8 +16,7 @@ const showLoginDialog = ref(false)
// Initialize preloader
const marketPreloader = useMarketPreloader()
// Initialize relay hub
const relayHub = relayHubComposable
// Relay hub initialization is now handled by the base module
// Hide navbar on login page
const showNavbar = computed(() => {
@ -33,18 +30,7 @@ async function handleLoginSuccess() {
// Trigger preloading after successful login
marketPreloader.preloadMarket()
// Connect to chat
if (!nostrChat.isConnected.value) {
try {
await nostrChat.connect()
// Load peers and subscribe to all for notifications
const peers = await nostrChat.loadPeers()
await nostrChat.subscribeToAllPeersForNotifications(peers)
} catch (error) {
console.error('Failed to initialize chat:', error)
}
}
// Chat initialization is now handled by the chat module
}
onMounted(async () => {
@ -55,12 +41,7 @@ onMounted(async () => {
console.error('Failed to initialize authentication:', error)
}
// Initialize relay hub
try {
await relayHub.initialize()
} catch (error) {
console.error('Failed to initialize relay hub:', error)
}
// Relay hub initialization is handled by the base module
})
// Watch for authentication changes and trigger preloading
@ -70,18 +51,7 @@ watch(() => auth.isAuthenticated.value, async (isAuthenticated) => {
console.log('User authenticated, triggering market preload...')
marketPreloader.preloadMarket()
}
if (!nostrChat.isConnected.value) {
console.log('User authenticated, connecting to chat...')
try {
await nostrChat.connect()
// Load peers and subscribe to all for notifications
const peers = await nostrChat.loadPeers()
await nostrChat.subscribeToAllPeersForNotifications(peers)
} catch (error) {
console.error('Failed to initialize chat:', error)
}
}
// Chat connection is now handled by the chat module automatically
}
}, { immediate: true })
</script>

View file

@ -15,7 +15,7 @@ import { LogoutConfirmDialog } from '@/components/ui/LogoutConfirmDialog'
import { auth } from '@/composables/useAuth'
import { useMarketPreloader } from '@/composables/useMarketPreloader'
import { useMarketStore } from '@/stores/market'
import { nostrChat } from '@/composables/useNostrChat'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useModularNavigation } from '@/composables/useModularNavigation'
interface NavigationItem {
@ -45,9 +45,12 @@ const totalBalance = computed(() => {
}, 0)
})
// Try to get chat service from DI (may not be available if chat module not loaded)
const chatService = tryInjectService(SERVICE_TOKENS.CHAT_SERVICE)
// Compute total unread messages (reactive)
const totalUnreadMessages = computed(() => {
return nostrChat.totalUnreadCount.value
return chatService?.totalUnreadCount?.value || 0
})
// Compute cart item count

View file

@ -1,55 +0,0 @@
import { computed } from 'vue'
import { useAsyncState } from '@vueuse/core'
import type { Event } from '@/lib/types/event'
import { fetchEvents } from '@/lib/api/events'
export function useEvents() {
const { state: events, isLoading, error: asyncError, execute: refresh } = useAsyncState(
fetchEvents,
[] as Event[],
{
immediate: true,
resetOnExecute: false,
}
)
const error = computed(() => {
if (asyncError.value) {
return {
message: asyncError.value instanceof Error
? asyncError.value.message
: 'An error occurred while fetching events'
}
}
return null
})
const sortedEvents = computed(() => {
return [...events.value].sort((a, b) =>
new Date(b.time).getTime() - new Date(a.time).getTime()
)
})
const upcomingEvents = computed(() => {
const now = new Date()
return sortedEvents.value.filter(event =>
new Date(event.event_start_date) > now
)
})
const pastEvents = computed(() => {
const now = new Date()
return sortedEvents.value.filter(event =>
new Date(event.event_end_date) < now
)
})
return {
events: sortedEvents,
upcomingEvents,
pastEvents,
isLoading,
error,
refresh,
}
}

View file

@ -1,928 +0,0 @@
import { ref, computed, readonly } from 'vue'
import { nip04, finalizeEvent, type EventTemplate } from 'nostr-tools'
import { hexToBytes } from '@/lib/utils/crypto'
import { getAuthToken } from '@/lib/config/lnbits'
import { config } from '@/lib/config'
import { relayHubComposable } from './useRelayHub'
import { useAuth } from './useAuth'
// Types
export interface ChatMessage {
id: string
content: string
created_at: number
sent: boolean
pubkey: string
}
export interface NostrRelayConfig {
url: string
read?: boolean
write?: boolean
}
// Add notification system for unread messages
interface UnreadMessageData {
lastReadTimestamp: number
unreadCount: number
processedMessageIds: Set<string> // Track which messages we've already counted as unread
}
const UNREAD_MESSAGES_KEY = 'nostr-chat-unread-messages'
// Get unread message data for a peer
const getUnreadData = (peerPubkey: string): UnreadMessageData => {
try {
const stored = localStorage.getItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`)
if (stored) {
const data = JSON.parse(stored)
// Convert the array back to a Set for processedMessageIds
return {
...data,
processedMessageIds: new Set(data.processedMessageIds || [])
}
}
return { lastReadTimestamp: 0, unreadCount: 0, processedMessageIds: new Set() }
} catch (error) {
console.warn('Failed to load unread data for peer:', peerPubkey, error)
return { lastReadTimestamp: 0, unreadCount: 0, processedMessageIds: new Set() }
}
}
// Save unread message data for a peer
const saveUnreadData = (peerPubkey: string, data: UnreadMessageData): void => {
try {
// Convert Set to array for localStorage serialization
const serializableData = {
...data,
processedMessageIds: Array.from(data.processedMessageIds)
}
localStorage.setItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`, JSON.stringify(serializableData))
} catch (error) {
console.warn('Failed to save unread data for peer:', peerPubkey, error)
}
}
export function useNostrChat() {
// Use the centralized relay hub
const relayHub = relayHubComposable
// Use the main authentication system
const auth = useAuth()
// State
const messages = ref<Map<string, ChatMessage[]>>(new Map())
const processedMessageIds = ref(new Set<string>())
const onMessageAdded = ref<((peerPubkey: string) => void) | null>(null)
// Reactive unread counts
const unreadCounts = ref<Map<string, number>>(new Map())
// Track latest message timestamp for each peer (for sorting)
const latestMessageTimestamps = ref<Map<string, number>>(new Map())
// Track peers globally
const peers = ref<any[]>([])
// Track malformed message IDs to prevent repeated processing attempts
const malformedMessageIds = ref(new Set<string>())
// Mark a message as malformed to prevent future processing attempts
const markMessageAsMalformed = (eventId: string) => {
malformedMessageIds.value.add(eventId)
// Also mark as processed to prevent retries
processedMessageIds.value.add(eventId)
}
// Clean up old malformed messages (call this periodically)
const cleanupMalformedMessages = () => {
// const now = Math.floor(Date.now() / 1000)
// const maxAge = 24 * 60 * 60 // 24 hours
// Clear old malformed message IDs to free memory
// This is a simple cleanup - in production you might want more sophisticated tracking
if (malformedMessageIds.value.size > 1000) {
console.log('Cleaning up malformed message tracking (clearing all)')
malformedMessageIds.value.clear()
}
}
// Set up periodic cleanup
let cleanupInterval: number | null = null
// Clean up resources
const cleanup = () => {
if (cleanupInterval) {
clearInterval(cleanupInterval)
cleanupInterval = null
console.log('Cleaned up malformed message tracking interval')
}
}
// Manually clear all malformed message tracking
const clearAllMalformedMessages = () => {
const count = malformedMessageIds.value.size
malformedMessageIds.value.clear()
console.log(`Cleared ${count} malformed message IDs from tracking`)
}
// Get statistics about malformed messages
const getMalformedMessageStats = () => {
return {
totalMalformed: malformedMessageIds.value.size,
totalProcessed: processedMessageIds.value.size,
malformedIds: Array.from(malformedMessageIds.value).slice(0, 10) // First 10 for debugging
}
}
// Computed - use relay hub's connection status and auth system
const isConnected = computed(() => relayHub.isConnected.value)
// Get current user from auth system
const currentUser = computed(() => {
const user = auth.currentUser.value
if (!user) {
return null
}
// Check if the user has a pubkey field
if (!user.pubkey) {
return null
}
// Check if the user has a prvkey field
if (!user.prvkey) {
return null
}
// Use the actual user data - assume prvkey and pubkey contain real Nostr keys
return {
pubkey: user.pubkey,
prvkey: user.prvkey
}
})
// Check if user is authenticated (has LNBits login)
const isAuthenticated = computed(() => {
return auth.currentUser.value !== null
})
// Check if user has complete Nostr keypair
const hasNostrKeys = computed(() => {
const user = currentUser.value
return user && user.pubkey && user.prvkey
})
// Get Nostr key status for debugging
const getNostrKeyStatus = () => {
const user = auth.currentUser.value
if (!user) {
return { hasUser: false, hasPubkey: false, hasPrvkey: false, message: 'No user logged in' }
}
return {
hasUser: true,
hasPubkey: !!user.pubkey,
hasPrvkey: !!user.prvkey,
message: user.pubkey && user.prvkey ? 'User has complete Nostr keypair' : 'User missing Nostr keys',
pubkey: user.pubkey
}
}
// Get unread count for a peer
const getUnreadCount = (peerPubkey: string): number => {
return unreadCounts.value.get(peerPubkey) || 0
}
// Get all unread counts
const getAllUnreadCounts = (): Map<string, number> => {
return new Map(unreadCounts.value)
}
// Get total unread count across all peers
const getTotalUnreadCount = (): number => {
let total = 0
for (const count of unreadCounts.value.values()) {
total += count
}
return total
}
// Get latest message timestamp for a peer
const getLatestMessageTimestamp = (peerPubkey: string): number => {
return latestMessageTimestamps.value.get(peerPubkey) || 0
}
// Get all latest message timestamps
const getAllLatestMessageTimestamps = (): Map<string, number> => {
return new Map(latestMessageTimestamps.value)
}
// Update latest message timestamp for a peer
const updateLatestMessageTimestamp = (peerPubkey: string, timestamp: number): void => {
const current = latestMessageTimestamps.value.get(peerPubkey) || 0
if (timestamp > current) {
latestMessageTimestamps.value.set(peerPubkey, timestamp)
}
}
// Update unread count for a peer
const updateUnreadCount = (peerPubkey: string, count: number): void => {
if (count > 0) {
unreadCounts.value.set(peerPubkey, count)
} else {
unreadCounts.value.delete(peerPubkey)
}
// Force reactivity
unreadCounts.value = new Map(unreadCounts.value)
// Save to localStorage
const unreadData = getUnreadData(peerPubkey)
unreadData.unreadCount = count
saveUnreadData(peerPubkey, unreadData)
}
// Mark messages as read for a peer
const markMessagesAsRead = (peerPubkey: string): void => {
const currentTimestamp = Math.floor(Date.now() / 1000)
// Update last read timestamp, reset unread count, and clear processed message IDs
const updatedData: UnreadMessageData = {
lastReadTimestamp: currentTimestamp,
unreadCount: 0,
processedMessageIds: new Set() // Clear processed messages when marking as read
}
saveUnreadData(peerPubkey, updatedData)
updateUnreadCount(peerPubkey, 0)
// Also clear any processed message IDs from the global set that might be from this peer
// This helps prevent duplicate message issues
}
// Load unread counts from localStorage
const loadUnreadCounts = (): void => {
try {
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
)
for (const key of keys) {
const peerPubkey = key.replace(`${UNREAD_MESSAGES_KEY}-`, '')
const unreadData = getUnreadData(peerPubkey)
// Recalculate unread count based on actual messages and lastReadTimestamp
const peerMessages = messages.value.get(peerPubkey) || []
let actualUnreadCount = 0
for (const message of peerMessages) {
// Only count messages not sent by us and created after last read timestamp
if (!message.sent && message.created_at > unreadData.lastReadTimestamp) {
actualUnreadCount++
}
}
// Update the stored count to match reality
if (actualUnreadCount !== unreadData.unreadCount) {
unreadData.unreadCount = actualUnreadCount
saveUnreadData(peerPubkey, unreadData)
}
if (actualUnreadCount > 0) {
unreadCounts.value.set(peerPubkey, actualUnreadCount)
}
}
} catch (error) {
console.warn('Failed to load unread counts:', error)
}
}
// Initialize unread counts on startup
loadUnreadCounts()
// Clear unread count for a peer
// const clearUnreadCount = (peerPubkey: string): void => {
// unreadCounts.value.delete(peerPubkey)
//
// // Clear from localStorage
// const unreadData = getUnreadData(peerPubkey)
// unreadData.unreadCount = 0
// saveUnreadData(peerPubkey, unreadData)
// }
// Clear all unread counts
const clearAllUnreadCounts = (): void => {
unreadCounts.value.clear()
// Clear from localStorage for all peers
for (const [peerPubkey] of messages.value) {
const unreadData = getUnreadData(peerPubkey)
unreadData.unreadCount = 0
saveUnreadData(peerPubkey, unreadData)
}
// Also clear from localStorage for all stored keys
try {
const keys = Object.keys(localStorage).filter(key =>
key.startsWith(`${UNREAD_MESSAGES_KEY}-`)
)
for (const key of keys) {
const peerPubkey = key.replace(`${UNREAD_MESSAGES_KEY}-`, '')
const unreadData = getUnreadData(peerPubkey)
unreadData.unreadCount = 0
saveUnreadData(peerPubkey, unreadData)
}
} catch (error) {
console.warn('Failed to clear unread counts from localStorage:', error)
}
}
// Clear processed message IDs for a peer
const clearProcessedMessageIds = (peerPubkey: string): void => {
const unreadData = getUnreadData(peerPubkey)
unreadData.processedMessageIds.clear()
saveUnreadData(peerPubkey, unreadData)
}
// Debug unread data for a peer
const debugUnreadData = (peerPubkey: string): void => {
// Function kept for potential future debugging
getUnreadData(peerPubkey)
}
// Get relay configuration
const getRelays = (): NostrRelayConfig[] => {
return config.nostr.relays.map(url => ({
url,
read: true,
write: true
}))
}
// Connect using the relay hub
const connect = async () => {
try {
// The relay hub should already be initialized by the app
if (!relayHub.isConnected.value) {
await relayHub.connect()
}
// Set up periodic cleanup of malformed messages
if (!cleanupInterval) {
cleanupInterval = setInterval(cleanupMalformedMessages, 5 * 60 * 1000) as unknown as number // Every 5 minutes
console.log('Set up periodic cleanup of malformed messages')
}
} catch (error) {
console.error('Failed to connect to relays:', error)
throw error
}
}
// Disconnect using the relay hub
const disconnect = () => {
// Note: We don't disconnect the relay hub here as other components might be using it
// The relay hub will be managed at the app level
}
// 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 a specific peer for messages
const subscribeToPeer = async (peerPubkey: string) => {
if (!currentUser.value) {
console.warn('Cannot subscribe to peer: no user logged in')
return
}
if (!currentUser.value.pubkey) {
console.warn('Cannot subscribe to peer: no public key available')
return
}
// Check if we have a pool and are connected
if (!relayHub.isConnected.value) {
console.warn('Not connected to relays - attempting to connect...')
await connect()
}
if (!relayHub.isConnected.value) {
throw new Error('Failed to initialize Nostr pool')
}
try {
// Subscribe to direct messages (kind 4) from this peer
const filter = {
kinds: [4],
'#p': [currentUser.value.pubkey], // Messages where we are the recipient
authors: [peerPubkey] // Messages from this specific peer
}
// Use the relay hub to subscribe
const unsubscribe = relayHub.subscribe({
id: `peer-${peerPubkey}`,
filters: [filter],
onEvent: (event) => {
handleIncomingMessage(event, peerPubkey)
}
})
return unsubscribe
} catch (error) {
console.error('Failed to subscribe to peer:', error)
throw error
}
}
// Subscribe to a peer for notifications only (without loading full message history)
const subscribeToPeerForNotifications = async (peerPubkey: string) => {
if (!currentUser.value) {
console.warn('No user logged in - cannot subscribe to peer notifications')
return null
}
// Check if we have a pool and are connected
if (!relayHub.isConnected.value) {
console.warn('Not connected to relays - attempting to connect...')
await connect()
}
if (!relayHub.isConnected.value) {
throw new Error('Failed to initialize Nostr pool')
}
const myPubkey = currentUser.value.pubkey
// Subscribe to new messages only (no historical messages)
const relayConfigs = getRelays()
const filters = [
{
kinds: [4],
authors: [peerPubkey],
'#p': [myPubkey]
},
{
kinds: [4],
authors: [myPubkey],
'#p': [peerPubkey]
}
]
const unsubscribe = relayHub.subscribe({
id: `notifications-${peerPubkey}-${Date.now()}`,
filters,
relays: relayConfigs.map(r => r.url),
onEvent: (event: any) => {
handleIncomingMessage(event, peerPubkey)
},
onEose: () => {
// Notification subscription closed
}
})
return unsubscribe
}
// Handle incoming message
const handleIncomingMessage = async (event: any, peerPubkey: string) => {
if (!currentUser.value || !currentUser.value.prvkey) {
console.warn('Cannot decrypt message: no private key available')
return
}
// Check if we've already processed this message to prevent duplicates
if (processedMessageIds.value.has(event.id)) {
return
}
// Check if this message was previously identified as malformed
if (malformedMessageIds.value.has(event.id)) {
console.log('Skipping previously identified malformed message:', event.id)
return
}
try {
// For NIP-04 direct messages, always use peerPubkey as the second argument
// This is the public key of the other party in the conversation
const isSentByMe = event.pubkey === currentUser.value.pubkey
// Check for malformed messages before attempting decryption
if (typeof event.content !== 'string' || event.content.length === 0) {
console.warn('Skipping message with invalid content format:', {
eventId: event.id,
contentType: typeof event.content,
contentLength: event.content?.length
})
return
}
// Check for our old placeholder encryption format
if (event.content.includes('[ENCRYPTED]') && event.content.includes('[ENCRYPTED]')) {
console.warn('Skipping message with old placeholder encryption format:', {
eventId: event.id,
content: event.content.substring(0, 100) + '...'
})
return
}
// Check for other common malformed patterns
if (event.content.startsWith('[') || event.content.includes('ENCRYPTED')) {
console.warn('Skipping message with suspicious encryption format:', {
eventId: event.id,
content: event.content.substring(0, 100) + '...'
})
return
}
const decryptedContent = await nip04.decrypt(
currentUser.value.prvkey,
peerPubkey, // Always use peerPubkey for shared secret derivation
event.content
)
// Create chat message
const message: ChatMessage = {
id: event.id,
content: decryptedContent,
created_at: event.created_at,
sent: isSentByMe,
pubkey: event.pubkey
}
// Add to messages
if (!messages.value.has(peerPubkey)) {
messages.value.set(peerPubkey, [])
}
messages.value.get(peerPubkey)!.push(message)
// Mark as unread if not sent by us AND created after last read timestamp
if (!isSentByMe) {
const unreadData = getUnreadData(peerPubkey)
// Only count as unread if message was created after last read timestamp
if (event.created_at > unreadData.lastReadTimestamp) {
// Increment the unread count for this peer
const currentCount = unreadCounts.value.get(peerPubkey) || 0
const newCount = currentCount + 1
unreadCounts.value.set(peerPubkey, newCount)
// Force reactivity
unreadCounts.value = new Map(unreadCounts.value)
// Save to localStorage
unreadData.unreadCount = newCount
saveUnreadData(peerPubkey, unreadData)
}
}
// Update latest message timestamp
updateLatestMessageTimestamp(peerPubkey, event.created_at)
// Mark this message as processed to prevent duplicates
processedMessageIds.value.add(event.id)
// Trigger callback if set
if (onMessageAdded.value) {
onMessageAdded.value(peerPubkey)
}
} catch (error) {
// Provide more specific error handling for different types of failures
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
// Check for specific error patterns that indicate malformed messages
if (errorMessage.includes('join.decode') || errorMessage.includes('input should be string')) {
console.warn('Skipping malformed message (invalid NIP-04 format):', {
eventId: event.id,
pubkey: event.pubkey,
error: errorMessage,
contentPreview: typeof event.content === 'string' ? event.content.substring(0, 100) + '...' : 'Invalid content type'
})
markMessageAsMalformed(event.id)
return
}
if (errorMessage.includes('Invalid byte sequence') || errorMessage.includes('hex string')) {
console.warn('Skipping message with invalid hex encoding:', {
eventId: event.id,
pubkey: event.pubkey,
error: errorMessage
})
markMessageAsMalformed(event.id)
return
}
// For other decryption errors, log with more context
console.error('Failed to decrypt message:', {
eventId: event.id,
pubkey: event.pubkey,
error: errorMessage,
contentType: typeof event.content,
contentLength: event.content?.length,
contentPreview: typeof event.content === 'string' ? event.content.substring(0, 100) + '...' : 'Invalid content type'
})
}
}
// Send message to a peer
const sendMessage = async (peerPubkey: string, content: string) => {
if (!currentUser.value) {
throw new Error('No user logged in - please authenticate first')
}
// Check if we have the required Nostr keypair
if (!currentUser.value.prvkey) {
throw new Error('Nostr private key not available. Please ensure your LNBits account has Nostr keys configured.')
}
// Check if we have a pool and are connected
if (!relayHub.isConnected.value) {
console.warn('Not connected to relays - attempting to connect...')
await connect()
}
if (!relayHub.isConnected.value) {
throw new Error('Failed to initialize Nostr pool')
}
try {
// Validate keys before encryption
if (!currentUser.value.prvkey || !peerPubkey) {
throw new Error('Missing private key or peer public key')
}
// Ensure keys are in correct hex format (64 characters for private key, 64 characters for public key)
const privateKey = currentUser.value.prvkey.startsWith('0x')
? currentUser.value.prvkey.slice(2)
: currentUser.value.prvkey
const publicKey = peerPubkey.startsWith('0x')
? peerPubkey.slice(2)
: peerPubkey
if (privateKey.length !== 64) {
throw new Error(`Invalid private key length: ${privateKey.length} (expected 64)`)
}
if (publicKey.length !== 64) {
throw new Error(`Invalid public key length: ${publicKey.length} (expected 64)`)
}
// Validate hex format
const hexRegex = /^[0-9a-fA-F]+$/
if (!hexRegex.test(privateKey)) {
throw new Error(`Invalid private key format: contains non-hex characters`)
}
if (!hexRegex.test(publicKey)) {
throw new Error(`Invalid public key format: contains non-hex characters`)
}
// Encrypt the message
let encryptedContent: string
try {
encryptedContent = await nip04.encrypt(
privateKey,
publicKey,
content
)
} catch (encryptError) {
console.error('Encryption failed:', encryptError)
throw new Error(`Encryption failed: ${encryptError instanceof Error ? encryptError.message : String(encryptError)}`)
}
// Create the event template
const eventTemplate: EventTemplate = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', peerPubkey]],
content: encryptedContent
}
// Finalize the event (sign it)
const event = finalizeEvent(eventTemplate, hexToBytes(privateKey))
// Publish to relays using the relay hub
await relayHub.publishEvent(event)
// Add message to local state
const message: ChatMessage = {
id: event.id,
content,
created_at: event.created_at,
sent: true,
pubkey: currentUser.value.pubkey
}
// Add to processed IDs to prevent duplicate processing
processedMessageIds.value.add(event.id)
if (!messages.value.has(peerPubkey)) {
messages.value.set(peerPubkey, [])
}
messages.value.get(peerPubkey)!.push(message)
// Sort messages by timestamp
messages.value.get(peerPubkey)!.sort((a, b) => a.created_at - b.created_at)
// Force reactivity by triggering a change
messages.value = new Map(messages.value)
// Update latest message timestamp for this peer (for sorting)
updateLatestMessageTimestamp(peerPubkey, message.created_at)
// Trigger callback if set
if (onMessageAdded.value) {
onMessageAdded.value(peerPubkey)
}
} catch (error) {
console.error('Failed to send message:', error)
throw error
}
}
// Get messages for a specific peer
const getMessages = (peerPubkey: string): ChatMessage[] => {
return messages.value.get(peerPubkey) || []
}
// Clear messages for a specific peer
const clearMessages = (peerPubkey: string) => {
messages.value.delete(peerPubkey)
}
// Load peers from API
const loadPeers = async () => {
try {
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/pubkeys`, {
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Failed to load peers: ${response.status}`)
}
const data = await response.json()
const loadedPeers = data.map((peer: any) => ({
user_id: peer.user_id,
username: peer.username,
pubkey: peer.pubkey
}))
// Store peers in the singleton state
peers.value = loadedPeers
return loadedPeers
} catch (error) {
console.error('Failed to load peers:', error)
throw error
}
}
// Subscribe to all peers for notifications (without loading full message history)
const subscribeToAllPeersForNotifications = async (peers: any[]) => {
if (!peers.length) {
return
}
// Wait for connection to be established
if (!relayHub.isConnected.value) {
// Wait a bit for connection to establish
await new Promise(resolve => setTimeout(resolve, 1000))
if (!relayHub.isConnected.value) {
console.warn('Still not connected, skipping peer subscriptions')
return
}
}
// Subscribe to each peer for notifications
for (const peer of peers) {
try {
await subscribeToPeerForNotifications(peer.pubkey)
} catch (error) {
console.error(`Failed to subscribe to peer ${peer.username} (${peer.pubkey}):`, error)
}
}
}
return {
// State
isConnected: readonly(isConnected),
messages: readonly(messages),
isLoggedIn: readonly(isAuthenticated),
peers: readonly(peers),
// Reactive computed properties
totalUnreadCount: computed(() => getTotalUnreadCount()),
// Methods
connect,
disconnect,
subscribeToPeer,
subscribeToPeerForNotifications,
sendMessage,
getMessages,
clearMessages,
onMessageAdded,
// Notification methods
markMessagesAsRead,
getUnreadCount,
getAllUnreadCounts,
getTotalUnreadCount,
clearAllUnreadCounts,
clearProcessedMessageIds,
debugUnreadData,
getUnreadData,
// Timestamp methods (for sorting)
getLatestMessageTimestamp,
getAllLatestMessageTimestamps,
// Peer management methods
loadPeers,
subscribeToAllPeersForNotifications,
currentUser,
hasNostrKeys,
getNostrKeyStatus,
markMessageAsMalformed,
cleanupMalformedMessages,
clearAllMalformedMessages, // Add the new function to the return object
cleanup, // Add the cleanup function to the return object
getMalformedMessageStats // Add the new function to the return object
}
}
// Export singleton instance for global state
export const nostrChat = useNostrChat()

View file

@ -1,198 +0,0 @@
import { ref, computed, onUnmounted, readonly } from 'vue'
import { nostrclientHub, type SubscriptionConfig } from '../lib/nostr/nostrclientHub'
export function useNostrclientHub() {
// Reactive state
const isConnected = ref(false)
const isConnecting = ref(false)
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected')
const error = ref<Error | null>(null)
const activeSubscriptions = ref<Set<string>>(new Set())
// Reactive counts
const totalSubscriptionCount = ref(0)
const subscriptionDetails = ref<Array<{ id: string; filters: any[] }>>([])
// Computed properties
const connectionHealth = computed(() => {
return isConnected.value ? 100 : 0
})
// Initialize nostrclient hub
const initialize = async (): Promise<void> => {
try {
connectionStatus.value = 'connecting'
error.value = null
console.log('🔧 NostrclientHub: Initializing...')
await nostrclientHub.initialize()
console.log('🔧 NostrclientHub: Initialization successful')
// Set up event listeners
setupEventListeners()
connectionStatus.value = 'connected'
isConnected.value = true
console.log('🔧 NostrclientHub: Connection status set to connected')
} catch (err) {
const errorObj = err instanceof Error ? err : new Error('Failed to initialize NostrclientHub')
error.value = errorObj
connectionStatus.value = 'error'
isConnected.value = false
console.error('🔧 NostrclientHub: Failed to initialize:', errorObj)
throw errorObj
}
}
// Connect to nostrclient
const connect = async (): Promise<void> => {
try {
connectionStatus.value = 'connecting'
error.value = null
await nostrclientHub.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 nostrclient
const disconnect = (): void => {
nostrclientHub.disconnect()
connectionStatus.value = 'disconnected'
isConnected.value = false
error.value = null
}
// Subscribe to events
const subscribe = (config: SubscriptionConfig): (() => void) => {
try {
const unsubscribe = nostrclientHub.subscribe(config)
activeSubscriptions.value.add(config.id)
// Update reactive state
totalSubscriptionCount.value = nostrclientHub.totalSubscriptionCount
subscriptionDetails.value = nostrclientHub.subscriptionDetails
return () => {
unsubscribe()
activeSubscriptions.value.delete(config.id)
totalSubscriptionCount.value = nostrclientHub.totalSubscriptionCount
subscriptionDetails.value = nostrclientHub.subscriptionDetails
}
} catch (err) {
console.error('Failed to subscribe:', err)
throw err
}
}
// Publish an event
const publishEvent = async (event: any): Promise<void> => {
try {
await nostrclientHub.publishEvent(event)
} catch (err) {
console.error('Failed to publish event:', err)
throw err
}
}
// Query events
const queryEvents = async (filters: any[]): Promise<any[]> => {
try {
return await nostrclientHub.queryEvents(filters)
} catch (err) {
console.error('Failed to query events:', err)
throw err
}
}
// Set up event listeners
const setupEventListeners = () => {
nostrclientHub.on('connected', () => {
isConnected.value = true
isConnecting.value = false
connectionStatus.value = 'connected'
error.value = null
})
nostrclientHub.on('disconnected', () => {
isConnected.value = false
isConnecting.value = false
connectionStatus.value = 'disconnected'
})
nostrclientHub.on('error', (err: any) => {
error.value = err
connectionStatus.value = 'error'
})
nostrclientHub.on('connectionError', (err: any) => {
error.value = err
connectionStatus.value = 'error'
})
nostrclientHub.on('maxReconnectionAttemptsReached', () => {
error.value = new Error('Max reconnection attempts reached')
connectionStatus.value = 'error'
})
nostrclientHub.on('event', ({ subscriptionId, event }: any) => {
console.log('Received event for subscription:', subscriptionId, event.id)
})
nostrclientHub.on('eose', ({ subscriptionId }: any) => {
console.log('EOSE received for subscription:', subscriptionId)
})
nostrclientHub.on('notice', ({ message }: any) => {
console.log('Notice from nostrclient:', message)
})
nostrclientHub.on('eventPublished', ({ eventId }: any) => {
console.log('Event published successfully:', eventId)
})
}
// Clean up event listeners
const cleanup = () => {
nostrclientHub.removeAllListeners()
}
// Auto-cleanup on unmount
onUnmounted(() => {
cleanup()
})
return {
// State
isConnected: readonly(isConnected),
isConnecting: readonly(isConnecting),
connectionStatus: readonly(connectionStatus),
error: readonly(error),
activeSubscriptions: readonly(activeSubscriptions),
totalSubscriptionCount: readonly(totalSubscriptionCount),
subscriptionDetails: readonly(subscriptionDetails),
// Computed
connectionHealth: readonly(connectionHealth),
// Methods
initialize,
connect,
disconnect,
subscribe,
publishEvent,
queryEvents,
// Internal
cleanup
}
}

View file

@ -1,314 +0,0 @@
import { ref, computed } from 'vue'
import { nip04 } from 'nostr-tools'
import { relayHubComposable } from './useRelayHub'
import { useAuth } from './useAuth'
import { useMarketStore } from '@/stores/market'
import { decode } from 'light-bolt11-decoder'
// Nostrmarket Order interfaces based on the actual implementation
// Nostrmarket Order interfaces based on the actual implementation
interface OrderItem {
product_id: string
quantity: number
}
interface OrderContact {
nostr?: string
phone?: string
email?: string
}
// Direct message types from nostrmarket
enum DirectMessageType {
PLAIN_TEXT = -1,
CUSTOMER_ORDER = 0,
PAYMENT_REQUEST = 1,
ORDER_PAID_OR_SHIPPED = 2
}
// Event types for nostrmarket protocol
interface CustomerOrderEvent {
type: DirectMessageType.CUSTOMER_ORDER
id: string
items: OrderItem[]
contact?: OrderContact
shipping_id: string
message?: string
}
interface PaymentRequestEvent {
type: DirectMessageType.PAYMENT_REQUEST
id: string
message?: string
payment_options: Array<{
type: string
link: string
}>
}
interface OrderStatusEvent {
type: DirectMessageType.ORDER_PAID_OR_SHIPPED
id: string
message?: string
paid?: boolean
shipped?: boolean
}
// Helper function to extract expiry from bolt11 invoice
function extractExpiryFromBolt11(bolt11String: string): string | undefined {
try {
const decoded = decode(bolt11String)
console.log('Decoded bolt11 invoice:', {
amount: decoded.sections.find(section => section.name === 'amount')?.value,
expiry: decoded.expiry,
timestamp: decoded.sections.find(section => section.name === 'timestamp')?.value
})
// Calculate expiry date from timestamp + expiry seconds
const timestamp = decoded.sections.find(section => section.name === 'timestamp')?.value as number
const expirySeconds = decoded.expiry as number
if (timestamp && expirySeconds) {
const expiryDate = new Date((timestamp + expirySeconds) * 1000)
return expiryDate.toISOString()
}
return undefined
} catch (error) {
console.warn('Failed to extract expiry from bolt11:', error)
return undefined
}
}
export function useOrderEvents() {
const relayHub = relayHubComposable
const auth = useAuth()
const marketStore = useMarketStore()
// State
const isSubscribed = ref(false)
const lastEventTimestamp = ref(0)
const processedEventIds = ref(new Set<string>())
const subscriptionId = ref<string | null>(null)
// Computed
const currentUserPubkey = computed(() => auth.currentUser?.value?.pubkey)
const isReady = computed(() => {
const isAuth = auth.isAuthenticated
const isConnected = relayHub.isConnected.value
const hasPubkey = !!currentUserPubkey.value
return isAuth && isConnected && hasPubkey
})
// Subscribe to order events
const subscribeToOrderEvents = async () => {
if (!isReady.value || isSubscribed.value) {
return
}
try {
// Subscribe to direct messages (kind 4) that contain order information
const filters = [
{
kinds: [4], // NIP-04 encrypted direct messages
'#p': [currentUserPubkey.value].filter(Boolean) as string[],
since: lastEventTimestamp.value
}
]
relayHub.subscribe({
id: 'order-events',
filters,
onEvent: handleOrderEvent,
onEose: () => {
console.log('Order events subscription ended')
}
})
subscriptionId.value = 'order-events'
isSubscribed.value = true
console.log('Successfully subscribed to order events')
} catch (error) {
console.error('Failed to subscribe to order events:', error)
}
}
// Handle incoming order events
const handleOrderEvent = async (event: any) => {
if (processedEventIds.value.has(event.id)) {
return
}
processedEventIds.value.add(event.id)
lastEventTimestamp.value = Math.max(lastEventTimestamp.value, event.created_at)
try {
// Decrypt the message content
const decryptedContent = await nip04.decrypt(
auth.currentUser.value?.prvkey || '',
event.pubkey,
event.content
)
// Parse the JSON content
const jsonData = JSON.parse(decryptedContent)
// Handle different message types
switch (jsonData.type) {
case DirectMessageType.CUSTOMER_ORDER:
await handleCustomerOrder(jsonData as CustomerOrderEvent, event.pubkey)
break
case DirectMessageType.PAYMENT_REQUEST:
await handlePaymentRequest(jsonData as PaymentRequestEvent, event.pubkey)
break
case DirectMessageType.ORDER_PAID_OR_SHIPPED:
await handleOrderStatusUpdate(jsonData as OrderStatusEvent, event.pubkey)
break
default:
console.log('Unknown message type:', jsonData.type)
}
} catch (error) {
console.error('Error processing order event:', error)
}
}
// Handle customer order (type 0)
const handleCustomerOrder = async (orderData: CustomerOrderEvent, _senderPubkey: string) => {
console.log('Received customer order:', orderData)
// Create a basic order object from the event data
const order = {
id: orderData.id,
type: DirectMessageType.CUSTOMER_ORDER,
items: orderData.items,
contact: orderData.contact,
shipping_id: orderData.shipping_id,
message: orderData.message,
createdAt: Date.now(),
updatedAt: Date.now()
}
// Store the order in our local state
// Note: We're not using the complex Order interface from market store
// Instead, we're using the simple nostrmarket format
console.log('Processed customer order:', order)
}
// Handle payment request (type 1)
const handlePaymentRequest = async (paymentData: PaymentRequestEvent, _senderPubkey: string) => {
console.log('Received payment request:', paymentData)
// Find the lightning payment option
const lightningOption = paymentData.payment_options?.find(opt => opt.type === 'ln')
if (lightningOption) {
console.log('Lightning payment request:', lightningOption.link)
// Find the existing order by ID
const existingOrder = marketStore.orders[paymentData.id]
if (existingOrder) {
console.log('Found existing order, updating with payment request:', existingOrder.id)
// Try to extract actual expiry from bolt11
const actualExpiry = extractExpiryFromBolt11(lightningOption.link)
// Create lightning invoice object
const lightningInvoice = {
checking_id: '', // Will be extracted from bolt11 if needed
payment_hash: '', // Will be extracted from bolt11 if needed
wallet_id: '', // Not available from payment request
amount: existingOrder.total,
fee: 0, // Not available from payment request
bolt11: lightningOption.link,
status: 'pending',
memo: paymentData.message || 'Payment for order',
created_at: new Date().toISOString(),
expiry: actualExpiry // Use actual expiry from bolt11 decoding
}
// Update the order with the lightning invoice
marketStore.updateOrder(existingOrder.id, {
lightningInvoice,
status: 'pending',
paymentRequest: lightningOption.link,
updatedAt: Date.now()
})
console.log('Order updated with payment request:', existingOrder.id)
} else {
console.warn('Order not found for payment request:', paymentData.id)
}
}
}
// Handle order status update (type 2)
const handleOrderStatusUpdate = async (statusData: OrderStatusEvent, _senderPubkey: string) => {
console.log('Received order status update:', statusData)
// Update order status in local state
if (statusData.paid !== undefined) {
console.log(`Order ${statusData.id} payment status: ${statusData.paid}`)
}
if (statusData.shipped !== undefined) {
console.log(`Order ${statusData.id} shipping status: ${statusData.shipped}`)
}
}
// Unsubscribe from order events
const unsubscribeFromOrderEvents = () => {
if (subscriptionId.value) {
relayHub.cleanup()
subscriptionId.value = null
}
isSubscribed.value = false
console.log('Unsubscribed from order events')
}
// Watch for ready state changes
const watchReadyState = () => {
if (isReady.value && !isSubscribed.value) {
subscribeToOrderEvents()
} else if (!isReady.value && isSubscribed.value) {
unsubscribeFromOrderEvents()
}
}
// Watch for authentication changes
const watchAuthChanges = () => {
if (auth.isAuthenticated && relayHub.isConnected.value) {
subscribeToOrderEvents()
} else {
unsubscribeFromOrderEvents()
}
}
// Initialize subscription when ready
const initialize = () => {
if (isReady.value) {
subscribeToOrderEvents()
}
}
// Cleanup
const cleanup = () => {
unsubscribeFromOrderEvents()
processedEventIds.value.clear()
}
return {
// State
isSubscribed: computed(() => isSubscribed.value),
isReady: computed(() => isReady.value),
lastEventTimestamp: computed(() => lastEventTimestamp.value),
// Methods
subscribeToOrderEvents,
unsubscribeFromOrderEvents,
initialize,
cleanup,
watchReadyState,
watchAuthChanges
}
}

View file

@ -1,283 +0,0 @@
import { ref, computed, onMounted, onUnmounted, readonly } 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())
// Reactive relay counts - these will be updated when relayHub state changes
const connectedRelayCount = ref(0)
const totalRelayCount = ref(0)
const totalSubscriptionCount = ref(0)
// Reactive subscription details
const subscriptionDetails = ref<Array<{ id: string; filters: any[]; relays?: string[] }>>([])
// Computed properties
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.market.supportedRelays
console.log('🔧 RelayHub: Initializing with relay URLs:', relayUrls)
if (!relayUrls || relayUrls.length === 0) {
throw new Error('No relay URLs configured')
}
// Initialize the relay hub
console.log('🔧 RelayHub: Calling relayHub.initialize...')
await relayHub.initialize(relayUrls)
console.log('🔧 RelayHub: Initialization successful')
// Set up event listeners
setupEventListeners()
connectionStatus.value = 'connected'
isConnected.value = true
console.log('🔧 RelayHub: Connection status set to connected')
} 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('🔧 RelayHub: 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
}
}
// Get relay status
const getRelayStatus = (url: string): RelayStatus | undefined => {
return relayStatuses.value.find(status => status.url === url)
}
// Set up event listeners for relay hub events
const setupEventListeners = (): void => {
relayHub.on('connected', (count: number) => {
isConnected.value = true
connectionStatus.value = 'connected'
error.value = null
connectedRelayCount.value = count
totalRelayCount.value = relayHub.totalRelayCount
totalSubscriptionCount.value = relayHub.totalSubscriptionCount
})
relayHub.on('disconnected', () => {
isConnected.value = false
connectionStatus.value = 'disconnected'
error.value = null
connectedRelayCount.value = 0
totalSubscriptionCount.value = 0
})
relayHub.on('connectionError', (err: Error) => {
console.error('Connection error:', err)
error.value = err
connectionStatus.value = 'error'
isConnected.value = false
connectedRelayCount.value = 0
})
relayHub.on('allRelaysDisconnected', () => {
console.warn('All relays disconnected')
isConnected.value = false
connectionStatus.value = 'disconnected'
connectedRelayCount.value = 0
})
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'
connectedRelayCount.value = connected
totalRelayCount.value = total
})
relayHub.on('maxReconnectAttemptsReached', () => {
console.error('Max reconnection attempts reached')
connectionStatus.value = 'error'
isConnected.value = false
error.value = new Error('Max reconnection attempts reached')
connectedRelayCount.value = 0
})
relayHub.on('networkOffline', () => {
console.log('Network went offline')
connectionStatus.value = 'disconnected'
isConnected.value = false
connectedRelayCount.value = 0
})
// Subscription events
relayHub.on('subscriptionCreated', ({ count }: { id: string; count: number }) => {
totalSubscriptionCount.value = count
})
relayHub.on('subscriptionRemoved', ({ count }: { id: string; count: number }) => {
totalSubscriptionCount.value = count
})
// Update relay statuses periodically
const updateRelayStatuses = () => {
relayStatuses.value = relayHub.relayStatuses
// Also update the reactive counts to keep them in sync
connectedRelayCount.value = relayHub.connectedRelayCount
totalRelayCount.value = relayHub.totalRelayCount
totalSubscriptionCount.value = relayHub.totalSubscriptionCount
subscriptionDetails.value = relayHub.subscriptionDetails
}
// 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: readonly(isConnected),
connectionStatus: readonly(connectionStatus),
relayStatuses: readonly(relayStatuses),
error: readonly(error),
activeSubscriptions: readonly(activeSubscriptions),
connectedRelayCount: readonly(connectedRelayCount),
totalRelayCount: readonly(totalRelayCount),
totalSubscriptionCount: readonly(totalSubscriptionCount),
subscriptionDetails: readonly(subscriptionDetails),
connectionHealth: readonly(connectionHealth),
// Methods
initialize,
connect,
disconnect,
subscribe,
publishEvent,
queryEvents,
getRelayStatus,
getConnectionHealth: connectionHealth,
cleanup
}
}
// Export singleton instance for global state
export const relayHubComposable = useRelayHub()

View file

@ -1,242 +0,0 @@
import { ref, computed, onUnmounted } from 'vue'
import { purchaseTicket, checkPaymentStatus, payInvoiceWithWallet } from '@/lib/api/events'
import { useAuth } from './useAuth'
import { toast } from 'vue-sonner'
export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth()
// State
const isLoading = ref(false)
const error = ref<string | null>(null)
const paymentHash = ref<string | null>(null)
const paymentRequest = ref<string | null>(null)
const qrCode = ref<string | null>(null)
const isPaymentPending = ref(false)
const isPayingWithWallet = ref(false)
// Ticket QR code state
const ticketQRCode = ref<string | null>(null)
const purchasedTicketId = ref<string | null>(null)
const showTicketQR = ref(false)
// Computed properties
const canPurchase = computed(() => isAuthenticated.value && currentUser.value)
const userDisplay = computed(() => {
if (!currentUser.value) return null
return {
name: currentUser.value.username || currentUser.value.id,
shortId: currentUser.value.id.slice(0, 8)
}
})
const userWallets = computed(() => currentUser.value?.wallets || [])
const hasWalletWithBalance = computed(() =>
userWallets.value.some((wallet: any) => wallet.balance_msat > 0)
)
// Generate QR code for Lightning payment
async function generateQRCode(bolt11: string) {
try {
const qrcode = await import('qrcode')
const dataUrl = await qrcode.toDataURL(bolt11, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
qrCode.value = dataUrl
} catch (err) {
console.error('Error generating QR code:', err)
error.value = 'Failed to generate QR code'
}
}
// Generate QR code for ticket
async function generateTicketQRCode(ticketId: string) {
try {
const qrcode = await import('qrcode')
const ticketUrl = `ticket://${ticketId}`
const dataUrl = await qrcode.toDataURL(ticketUrl, {
width: 128,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
ticketQRCode.value = dataUrl
return dataUrl
} catch (error) {
console.error('Error generating ticket QR code:', error)
return null
}
}
// Pay with wallet
async function payWithWallet(paymentRequest: string) {
const walletWithBalance = userWallets.value.find((wallet: any) => wallet.balance_msat > 0)
if (!walletWithBalance) {
throw new Error('No wallet with sufficient balance found')
}
try {
await payInvoiceWithWallet(paymentRequest, walletWithBalance.id, walletWithBalance.adminkey)
return true
} catch (error) {
console.error('Wallet payment failed:', error)
throw error
}
}
// Purchase ticket for event
async function purchaseTicketForEvent(eventId: string) {
if (!canPurchase.value) {
throw new Error('User must be authenticated to purchase tickets')
}
isLoading.value = true
error.value = null
paymentHash.value = null
paymentRequest.value = null
qrCode.value = null
ticketQRCode.value = null
purchasedTicketId.value = null
showTicketQR.value = false
try {
// Get the invoice
const invoice = await purchaseTicket(eventId)
paymentHash.value = invoice.payment_hash
paymentRequest.value = invoice.payment_request
// Generate QR code for payment
await generateQRCode(invoice.payment_request)
// Try to pay with wallet if available
if (hasWalletWithBalance.value) {
isPayingWithWallet.value = true
try {
await payWithWallet(invoice.payment_request)
// If wallet payment succeeds, proceed to check payment status
await startPaymentStatusCheck(eventId, invoice.payment_hash)
} catch (walletError) {
// If wallet payment fails, fall back to manual payment
console.log('Wallet payment failed, falling back to manual payment:', walletError)
isPayingWithWallet.value = false
await startPaymentStatusCheck(eventId, invoice.payment_hash)
}
} else {
// No wallet balance, proceed with manual payment
await startPaymentStatusCheck(eventId, invoice.payment_hash)
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to purchase ticket'
console.error('Error purchasing ticket:', err)
} finally {
isLoading.value = false
}
}
// Start payment status check
async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true
let checkInterval: number | null = null
const checkPayment = async () => {
try {
const result = await checkPaymentStatus(eventId, hash)
if (result.paid) {
isPaymentPending.value = false
if (checkInterval) {
clearInterval(checkInterval)
}
// Generate ticket QR code
if (result.ticket_id) {
purchasedTicketId.value = result.ticket_id
await generateTicketQRCode(result.ticket_id)
showTicketQR.value = true
}
toast.success('Ticket purchased successfully!')
}
} catch (err) {
console.error('Error checking payment status:', err)
}
}
// Check immediately
await checkPayment()
// Then check every 2 seconds
checkInterval = setInterval(checkPayment, 2000) as unknown as number
}
// Stop payment status check
function stopPaymentStatusCheck() {
isPaymentPending.value = false
}
// Reset payment state
function resetPaymentState() {
isLoading.value = false
error.value = null
paymentHash.value = null
paymentRequest.value = null
qrCode.value = null
isPaymentPending.value = false
isPayingWithWallet.value = false
ticketQRCode.value = null
purchasedTicketId.value = null
showTicketQR.value = false
}
// Open Lightning wallet
function handleOpenLightningWallet() {
if (paymentRequest.value) {
window.open(`lightning:${paymentRequest.value}`, '_blank')
}
}
// Cleanup function
function cleanup() {
stopPaymentStatusCheck()
}
// Lifecycle
onUnmounted(() => {
cleanup()
})
return {
// State
isLoading,
error,
paymentHash,
paymentRequest,
qrCode,
isPaymentPending,
isPayingWithWallet,
ticketQRCode,
purchasedTicketId,
showTicketQR,
// Computed
canPurchase,
userDisplay,
userWallets,
hasWalletWithBalance,
// Actions
purchaseTicketForEvent,
handleOpenLightningWallet,
resetPaymentState,
cleanup,
generateTicketQRCode
}
}

View file

@ -1,123 +0,0 @@
import { computed } from 'vue'
import { useAsyncState } from '@vueuse/core'
import type { Ticket } from '@/lib/types/event'
import { fetchUserTickets } from '@/lib/api/events'
import { useAuth } from './useAuth'
interface GroupedTickets {
eventId: string
tickets: Ticket[]
paidCount: number
pendingCount: number
registeredCount: number
}
export function useUserTickets() {
const { isAuthenticated, currentUser } = useAuth()
const { state: tickets, isLoading, error: asyncError, execute: refresh } = useAsyncState(
async () => {
if (!isAuthenticated.value || !currentUser.value) {
return []
}
return await fetchUserTickets(currentUser.value.id)
},
[] as Ticket[],
{
immediate: false,
resetOnExecute: false,
}
)
const error = computed(() => {
if (asyncError.value) {
return {
message: asyncError.value instanceof Error
? asyncError.value.message
: 'An error occurred while fetching tickets'
}
}
return null
})
const sortedTickets = computed(() => {
return [...tickets.value].sort((a, b) =>
new Date(b.time).getTime() - new Date(a.time).getTime()
)
})
const paidTickets = computed(() => {
return sortedTickets.value.filter(ticket => ticket.paid)
})
const pendingTickets = computed(() => {
return sortedTickets.value.filter(ticket => !ticket.paid)
})
const registeredTickets = computed(() => {
return sortedTickets.value.filter(ticket => ticket.registered)
})
const unregisteredTickets = computed(() => {
return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered)
})
// Group tickets by event
const groupedTickets = computed(() => {
const groups = new Map<string, GroupedTickets>()
sortedTickets.value.forEach(ticket => {
if (!groups.has(ticket.event)) {
groups.set(ticket.event, {
eventId: ticket.event,
tickets: [],
paidCount: 0,
pendingCount: 0,
registeredCount: 0
})
}
const group = groups.get(ticket.event)!
group.tickets.push(ticket)
if (ticket.paid) {
group.paidCount++
} else {
group.pendingCount++
}
if (ticket.registered) {
group.registeredCount++
}
})
// Convert to array and sort by most recent ticket in each group
return Array.from(groups.values()).sort((a, b) => {
const aLatest = Math.max(...a.tickets.map(t => new Date(t.time).getTime()))
const bLatest = Math.max(...b.tickets.map(t => new Date(t.time).getTime()))
return bLatest - aLatest
})
})
// Load tickets when authenticated
const loadTickets = async () => {
if (isAuthenticated.value && currentUser.value) {
await refresh()
}
}
return {
// State
tickets: sortedTickets,
paidTickets,
pendingTickets,
registeredTickets,
unregisteredTickets,
groupedTickets,
isLoading,
error,
// Actions
refresh: loadTickets,
}
}

View file

@ -18,7 +18,6 @@ interface PushConfig {
interface MarketConfig {
defaultNaddr: string
supportedRelays: string[]
lightningEnabled: boolean
defaultCurrency: string
}
@ -65,14 +64,6 @@ export const config: AppConfig = {
},
market: {
defaultNaddr: import.meta.env.VITE_MARKET_NADDR || '',
supportedRelays: parseJsonEnv(import.meta.env.VITE_MARKET_RELAYS, [
'ws://127.0.0.1:7777',
'wss://relay.damus.io',
'wss://relay.snort.social',
'wss://nostr-pub.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://nostr.walletofsatoshi.com'
]),
lightningEnabled: Boolean(import.meta.env.VITE_LIGHTNING_ENABLED),
defaultCurrency: import.meta.env.VITE_MARKET_DEFAULT_CURRENCY || 'sat'
},
@ -119,7 +110,8 @@ export const configUtils = {
},
getMarketRelays: (): string[] => {
return config.market.supportedRelays
// Market now uses the same relays as the main Nostr configuration
return config.nostr.relays
}
}

View file

@ -375,7 +375,7 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { nostrChat } from '@/composables/useNostrChat'
import { useChat } from '../composables/useChat'
import { useFuzzySearch } from '@/composables/useFuzzySearch'
@ -386,8 +386,11 @@ interface Peer {
pubkey: string
}
// Initialize chat composable
const chat = useChat()
// State
const peers = computed(() => nostrChat.peers.value)
const peers = computed(() => chat.peers.value)
const selectedPeer = ref<Peer | null>(null)
const messageInput = ref('')
@ -400,20 +403,30 @@ const scrollTarget = ref<HTMLElement | null>(null)
// Mobile detection
const isMobile = ref(false)
// Nostr chat composable (singleton)
const {
isConnected,
messages,
connect,
disconnect,
subscribeToPeer,
sendMessage: sendNostrMessage,
onMessageAdded,
markMessagesAsRead,
getUnreadCount,
totalUnreadCount,
getLatestMessageTimestamp
} = nostrChat
// Get methods and state from chat composable
// Note: The modular chat service handles connection and peer management automatically
const isConnected = computed(() => true) // Chat service manages connection
const messages = ref(new Map()) // Local messages map for compatibility
const totalUnreadCount = computed(() => chat.totalUnreadCount.value)
// Adapter functions for compatibility with existing code
const connect = async () => {} // Connection handled by chat service
const disconnect = () => {} // Handled by chat service
const subscribeToPeer = async (peer: string) => {} // Handled by chat service
const sendNostrMessage = async (peer: string, content: string) => {
chat.selectPeer(peer)
await chat.sendMessage(content)
}
const onMessageAdded = (callback: Function) => {} // Event handling via chat service
const markMessagesAsRead = (peer: string) => chat.markAsRead(peer)
const getUnreadCount = (peer: string) => {
const peerData = chat.peers.value.find(p => p.pubkey === peer)
return peerData?.unreadCount || 0
}
const getLatestMessageTimestamp = (peer: string) => {
const msgs = messages.value.get(peer) || []
return msgs.length > 0 ? msgs[msgs.length - 1].created_at : 0
}
// Computed
const currentMessages = computed(() => {
@ -498,7 +511,7 @@ const goBackToPeers = () => {
const refreshPeers = async () => {
isLoading.value = true
try {
await nostrChat.loadPeers()
// Peers are loaded automatically by the chat service
} catch (error) {
console.error('Failed to refresh peers:', error)
} finally {
@ -601,7 +614,7 @@ onMounted(async () => {
// If no peers loaded, load them
if (peers.value.length === 0) {
await nostrChat.loadPeers()
// Peers are loaded automatically by the chat service
}
})

View file

@ -4,7 +4,7 @@ import { onUnmounted } from 'vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTicketPurchase } from '@/composables/useTicketPurchase'
import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuth'
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'

View file

@ -201,13 +201,14 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useOrderEvents } from '@/composables/useOrderEvents'
// import { useOrderEvents } from '@/composables/useOrderEvents' // TODO: Move to market module
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, X } from 'lucide-vue-next'
// const marketStore = useMarketStore()
const orderEvents = useOrderEvents()
// const orderEvents = useOrderEvents() // TODO: Move to market module
const orderEvents = { isSubscribed: ref(false) } // Temporary mock
// Local state
const activeSettingsTab = ref('store')

View file

@ -231,8 +231,8 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMarketStore } from '@/stores/market'
import { useOrderEvents } from '@/composables/useOrderEvents'
import { relayHubComposable } from '@/composables/useRelayHub'
// import { useOrderEvents } from '@/composables/useOrderEvents' // TODO: Move to market module
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { auth } from '@/composables/useAuth'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -242,8 +242,9 @@ import type { OrderStatus } from '@/stores/market'
const router = useRouter()
const marketStore = useMarketStore()
const relayHub = relayHubComposable
const orderEvents = useOrderEvents()
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
// const orderEvents = useOrderEvents() // TODO: Move to market module
const orderEvents = { isSubscribed: ref(false), subscribeToOrderEvents: () => {}, cleanup: () => {} } // Temporary mock
// Local state
const statusFilter = ref('')

View file

@ -463,14 +463,17 @@ export function useMarket() {
// Connect to market
const connectToMarket = async () => {
try {
// Connect to market
// Connect to relay hub
await relayHub.connect()
// Use existing relay hub connection (should already be connected by base module)
isConnected.value = relayHub.isConnected.value
if (!isConnected.value) {
throw new Error('Failed to connect to Nostr relays')
console.warn('RelayHub not connected, attempting to connect...')
await relayHub.connect()
isConnected.value = relayHub.isConnected.value
if (!isConnected.value) {
throw new Error('Failed to connect to Nostr relays')
}
}
// Market connected successfully

View file

@ -7,14 +7,14 @@ import { Button } from '@/components/ui/button'
import { formatDistanceToNow } from 'date-fns'
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
import { config, configUtils } from '@/lib/config'
import { relayHubComposable } from '@/composables/useRelayHub'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
const props = defineProps<{
relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general'
}>()
const relayHub = relayHubComposable
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
// Reactive state
const notes = ref<any[]>([])

View file

@ -7,7 +7,8 @@
</template>
<script setup lang="ts">
import NostrFeed from '@/components/nostr/NostrFeed.vue'
// NostrFeed is now registered globally by the nostr-feed module
// No need to import it directly - use the modular version
import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
</script>