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:
parent
63de083909
commit
17c07c37a0
17 changed files with 63 additions and 2222 deletions
38
src/App.vue
38
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
|
|
|
|||
|
|
@ -463,15 +463,18 @@ export function useMarket() {
|
|||
// Connect to market
|
||||
const connectToMarket = async () => {
|
||||
try {
|
||||
// Connect to market
|
||||
// Use existing relay hub connection (should already be connected by base module)
|
||||
isConnected.value = relayHub.isConnected.value
|
||||
|
||||
// Connect to relay hub
|
||||
if (!isConnected.value) {
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]>([])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue