import { ref, computed } from 'vue' import { eventBus } from '@/core/event-bus' import { BaseService } from '@/core/base/BaseService' import { nip04, finalizeEvent, type Event, type EventTemplate } from 'nostr-tools' import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types' import { getAuthToken } from '@/lib/config/lnbits' import { config } from '@/lib/config' export class ChatService extends BaseService { // Service metadata protected readonly metadata = { name: 'ChatService', version: '1.0.0', dependencies: ['RelayHub', 'AuthService', 'VisibilityService', 'StorageService'] } // Service-specific state private messages = ref>(new Map()) private peers = ref>(new Map()) private config: ChatConfig private subscriptionUnsubscriber?: () => void private marketMessageHandler?: (event: any) => Promise private visibilityUnsubscribe?: () => void private isFullyInitialized = false private authCheckInterval?: ReturnType constructor(config: ChatConfig) { super() this.config = config this.loadPeersFromStorage() } // Register market message handler for forwarding market-related DMs setMarketMessageHandler(handler: (event: any) => Promise) { this.marketMessageHandler = handler } /** * Service-specific initialization (called by BaseService) */ protected async onInitialize(): Promise { this.debug('Chat service onInitialize called') // Check both injected auth service AND global auth composable const { auth } = await import('@/composables/useAuth') const hasAuthService = this.authService?.user?.value?.pubkey const hasGlobalAuth = auth.currentUser.value?.pubkey this.debug('Auth detection:', { hasAuthService: !!hasAuthService, hasGlobalAuth: !!hasGlobalAuth, authServicePubkey: hasAuthService ? hasAuthService.substring(0, 10) + '...' : null, globalAuthPubkey: hasGlobalAuth ? hasGlobalAuth.substring(0, 10) + '...' : null }) if (!hasAuthService && !hasGlobalAuth) { this.debug('User not authenticated yet, deferring full initialization with periodic check') // Listen for auth events to complete initialization when user logs in const unsubscribe = eventBus.on('auth:login', async () => { this.debug('Auth login detected, completing chat initialization...') unsubscribe() if (this.authCheckInterval) { clearInterval(this.authCheckInterval) this.authCheckInterval = undefined } // Re-inject dependencies and complete initialization await this.waitForDependencies() await this.completeInitialization() }) // Also check periodically in case we missed the auth event this.authCheckInterval = setInterval(async () => { const { auth } = await import('@/composables/useAuth') const hasAuthService = this.authService?.user?.value?.pubkey const hasGlobalAuth = auth.currentUser.value?.pubkey if (hasAuthService || hasGlobalAuth) { this.debug('Auth detected via periodic check, completing initialization') if (this.authCheckInterval) { clearInterval(this.authCheckInterval) this.authCheckInterval = undefined } unsubscribe() await this.waitForDependencies() await this.completeInitialization() } }, 2000) // Check every 2 seconds return } await this.completeInitialization() } /** * Complete the initialization once all dependencies are available */ private async completeInitialization(): Promise { if (this.isFullyInitialized) { this.debug('Chat service already fully initialized, skipping') return } this.debug('Completing chat service initialization...') // Load peers from storage first this.loadPeersFromStorage() // Load peers from API await this.loadPeersFromAPI().catch(error => { console.warn('Failed to load peers from API:', error) }) // Initialize message handling (subscription + history loading) await this.initializeMessageHandling() // Register with visibility service this.registerWithVisibilityService() this.isFullyInitialized = true this.debug('Chat service fully initialized and ready!') } private isFullyInitialized = false // Initialize message handling (subscription + history loading) async initializeMessageHandling(): Promise { // Set up real-time subscription await this.setupMessageSubscription() // Load message history for known peers await this.loadMessageHistory() } // Computed properties get allPeers() { return computed(() => Array.from(this.peers.value.values())) } get totalUnreadCount() { return computed(() => { return Array.from(this.peers.value.values()) .reduce((total, peer) => total + peer.unreadCount, 0) }) } get isReady() { return this.isInitialized } // Get messages for a specific peer getMessages(peerPubkey: string): ChatMessage[] { return this.messages.value.get(peerPubkey) || [] } // Get peer by pubkey getPeer(pubkey: string): ChatPeer | undefined { return this.peers.value.get(pubkey) } // Add or update a peer addPeer(pubkey: string, name?: string): ChatPeer { let peer = this.peers.value.get(pubkey) if (!peer) { peer = { pubkey, name: name || `User ${pubkey.slice(0, 8)}`, unreadCount: 0, lastSeen: Date.now() } this.peers.value.set(pubkey, peer) this.savePeersToStorage() eventBus.emit('chat:peer-added', { peer }, 'chat-service') } else if (name && name !== peer.name) { peer.name = name this.savePeersToStorage() } return peer } // Add a message addMessage(peerPubkey: string, message: ChatMessage): void { if (!this.messages.value.has(peerPubkey)) { this.messages.value.set(peerPubkey, []) } const peerMessages = this.messages.value.get(peerPubkey)! // Avoid duplicates if (!peerMessages.some(m => m.id === message.id)) { peerMessages.push(message) // Sort by timestamp peerMessages.sort((a, b) => a.created_at - b.created_at) // Limit message count if (peerMessages.length > this.config.maxMessages) { peerMessages.splice(0, peerMessages.length - this.config.maxMessages) } // Update peer info const peer = this.addPeer(peerPubkey) peer.lastMessage = message peer.lastSeen = Date.now() // Update unread count if message is not sent by us if (!message.sent) { this.updateUnreadCount(peerPubkey, message) } // Emit events const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received' eventBus.emit(eventType, { message, peerPubkey }, 'chat-service') } } // Mark messages as read for a peer markAsRead(peerPubkey: string): void { const peer = this.peers.value.get(peerPubkey) if (peer && peer.unreadCount > 0) { peer.unreadCount = 0 // Save unread state const unreadData: UnreadMessageData = { lastReadTimestamp: Date.now(), unreadCount: 0, processedMessageIds: new Set() } this.saveUnreadData(peerPubkey, unreadData) eventBus.emit('chat:unread-count-changed', { peerPubkey, count: 0, totalUnread: this.totalUnreadCount.value }, 'chat-service') } } // Refresh peers from API async refreshPeers(): Promise { // Check if we should trigger full initialization const { auth } = await import('@/composables/useAuth') const hasAuth = this.authService?.user?.value?.pubkey || auth.currentUser.value?.pubkey if (!this.isFullyInitialized && hasAuth) { console.log('💬 Refresh peers triggered full initialization') await this.completeInitialization() } return this.loadPeersFromAPI() } // Check if services are available for messaging private async checkServicesAvailable(): Promise<{ relayHub: any; authService: any; userPubkey: string; userPrivkey: string } | null> { // Check both injected auth service AND global auth composable const { auth } = await import('@/composables/useAuth') const hasAuthService = this.authService?.user?.value?.prvkey const hasGlobalAuth = auth.currentUser.value?.prvkey const userPubkey = hasAuthService ? this.authService.user.value.pubkey : auth.currentUser.value?.pubkey const userPrivkey = hasAuthService ? this.authService.user.value.prvkey : auth.currentUser.value?.prvkey if (!this.relayHub || (!hasAuthService && !hasGlobalAuth)) { return null } if (!this.relayHub.isConnected) { return null } return { relayHub: this.relayHub, authService: this.authService || auth, userPubkey: userPubkey!, userPrivkey: userPrivkey! } } // Send a message async sendMessage(peerPubkey: string, content: string): Promise { try { const services = await this.checkServicesAvailable() if (!services) { throw new Error('Chat services not ready. Please wait for connection to establish.') } const { relayHub, userPrivkey, userPubkey } = services // Encrypt the message using NIP-04 const encryptedContent = await nip04.encrypt(userPrivkey, peerPubkey, content) // Create Nostr event for the encrypted message (kind 4 = encrypted direct message) const eventTemplate: EventTemplate = { kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', peerPubkey]], content: encryptedContent } // Finalize the event with signature const signedEvent = finalizeEvent(eventTemplate, userPrivkey) // Create local message for immediate display const message: ChatMessage = { id: signedEvent.id, content, created_at: signedEvent.created_at, sent: true, pubkey: userPubkey } // Add to local messages immediately this.addMessage(peerPubkey, message) // Publish to Nostr relays const result = await relayHub.publishEvent(signedEvent) console.log('Message published to relays:', { success: result.success, total: result.total }) } catch (error) { console.error('Failed to send message:', error) throw error } } // Private methods private updateUnreadCount(peerPubkey: string, message: ChatMessage): void { const unreadData = this.getUnreadData(peerPubkey) if (!unreadData.processedMessageIds.has(message.id)) { unreadData.processedMessageIds.add(message.id) unreadData.unreadCount++ const peer = this.peers.value.get(peerPubkey) if (peer) { peer.unreadCount = unreadData.unreadCount this.savePeersToStorage() } this.saveUnreadData(peerPubkey, unreadData) eventBus.emit('chat:unread-count-changed', { peerPubkey, count: unreadData.unreadCount, totalUnread: this.totalUnreadCount.value }, 'chat-service') } } private getUnreadData(peerPubkey: string): UnreadMessageData { const data = this.storageService.getUserData(`chat-unread-messages-${peerPubkey}`, { lastReadTimestamp: 0, unreadCount: 0, processedMessageIds: [] }) return { ...data, processedMessageIds: new Set(data.processedMessageIds || []) } } private saveUnreadData(peerPubkey: string, data: UnreadMessageData): void { const serializable = { ...data, processedMessageIds: Array.from(data.processedMessageIds) } this.storageService.setUserData(`chat-unread-messages-${peerPubkey}`, serializable) } // Load peers from API async loadPeersFromAPI(): Promise { try { const authToken = getAuthToken() if (!authToken) { console.warn('💬 No authentication token found for loading peers from API') throw new Error('No authentication token found') } const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006' console.log('💬 Loading peers from API:', `${API_BASE_URL}/api/v1/auth/nostr/pubkeys`) const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/pubkeys`, { headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' } }) if (!response.ok) { const errorText = await response.text() console.error('💬 API response error:', response.status, errorText) throw new Error(`Failed to load peers: ${response.status} - ${errorText}`) } const data = await response.json() console.log('💬 API returned', data?.length || 0, 'peers') if (!Array.isArray(data)) { console.warn('💬 Invalid API response format - expected array, got:', typeof data) return } // Don't clear existing peers - merge instead data.forEach((peer: any) => { if (!peer.pubkey) { console.warn('💬 Skipping peer without pubkey:', peer) return } const chatPeer: ChatPeer = { pubkey: peer.pubkey, name: peer.username || `User ${peer.pubkey.slice(0, 8)}`, unreadCount: 0, lastSeen: Date.now() } this.peers.value.set(peer.pubkey, chatPeer) }) // Save to storage this.savePeersToStorage() console.log(`✅ Loaded ${data.length} peers from API, total peers now: ${this.peers.value.size}`) } catch (error) { console.error('❌ Failed to load peers from API:', error) // Don't re-throw - peers from storage are still available } } private loadPeersFromStorage(): void { // Skip loading peers in constructor as StorageService may not be available yet // This will be called later during initialization when dependencies are ready if (!this.isInitialized.value || !this.storageService) { this.debug('Skipping peer loading from storage - not initialized or storage unavailable') return } try { const peersArray = this.storageService.getUserData('chat-peers', []) as ChatPeer[] console.log('💬 Loading', peersArray.length, 'peers from storage') peersArray.forEach(peer => { this.peers.value.set(peer.pubkey, peer) }) } catch (error) { console.warn('💬 Failed to load peers from storage:', error) } } private savePeersToStorage(): void { const peersArray = Array.from(this.peers.value.values()) this.storageService.setUserData('chat-peers', peersArray) } // Load message history for known peers private async loadMessageHistory(): Promise { try { // Check both injected auth service AND global auth composable const { auth } = await import('@/composables/useAuth') const hasAuthService = this.authService?.user?.value?.pubkey const hasGlobalAuth = auth.currentUser.value?.pubkey const userPubkey = hasAuthService ? this.authService.user.value.pubkey : auth.currentUser.value?.pubkey const userPrivkey = hasAuthService ? this.authService.user.value.prvkey : auth.currentUser.value?.prvkey if (!this.relayHub || (!hasAuthService && !hasGlobalAuth)) { console.warn('Cannot load message history: missing services') return } if (!userPubkey || !userPrivkey) { console.warn('Cannot load message history: missing user keys') return } const peerPubkeys = Array.from(this.peers.value.keys()) if (peerPubkeys.length === 0) { console.log('No peers to load message history for') return } console.log('Loading message history for', peerPubkeys.length, 'peers') // Query historical messages (kind 4) to/from known peers // We need separate queries for sent vs received messages due to different tagging const receivedEvents = await this.relayHub.queryEvents([ { kinds: [4], authors: peerPubkeys, // Messages from peers '#p': [userPubkey], // Messages tagged to us limit: 100 } ]) const sentEvents = await this.relayHub.queryEvents([ { kinds: [4], authors: [userPubkey], // Messages from us '#p': peerPubkeys, // Messages tagged to peers limit: 100 } ]) const events = [...receivedEvents, ...sentEvents] .sort((a, b) => a.created_at - b.created_at) // Sort by timestamp console.log('Found', events.length, 'historical messages:', receivedEvents.length, 'received,', sentEvents.length, 'sent') // Process historical messages for (const event of events) { try { const isFromUs = event.pubkey === userPubkey const peerPubkey = isFromUs ? event.tags.find((tag: string[]) => tag[0] === 'p')?.[1] // Get recipient from tag : event.pubkey // Sender is the peer if (!peerPubkey || peerPubkey === userPubkey) continue // Decrypt the message const decryptedContent = await nip04.decrypt(userPrivkey, peerPubkey, event.content) // Create a chat message const message: ChatMessage = { id: event.id, content: decryptedContent, created_at: event.created_at, sent: isFromUs, pubkey: event.pubkey } // Add the message (will avoid duplicates) this.addMessage(peerPubkey, message) } catch (error) { console.error('Failed to decrypt historical message:', error) } } console.log('Message history loaded successfully') } catch (error) { console.error('Failed to load message history:', error) } } // Setup subscription for incoming messages private async setupMessageSubscription(): Promise { try { // Check both injected auth service AND global auth composable const { auth } = await import('@/composables/useAuth') const hasAuthService = this.authService?.user?.value?.pubkey const hasGlobalAuth = auth.currentUser.value?.pubkey const userPubkey = hasAuthService ? this.authService.user.value.pubkey : auth.currentUser.value?.pubkey this.debug('Setup message subscription auth check:', { hasAuthService: !!hasAuthService, hasGlobalAuth: !!hasGlobalAuth, hasRelayHub: !!this.relayHub, relayHubConnected: this.relayHub?.isConnected, userPubkey: userPubkey ? userPubkey.substring(0, 10) + '...' : null }) if (!this.relayHub || (!hasAuthService && !hasGlobalAuth)) { console.warn('💬 Cannot setup message subscription: missing services') // Retry after 2 seconds setTimeout(() => this.setupMessageSubscription(), 2000) return } if (!this.relayHub.isConnected) { console.warn('💬 RelayHub not connected, waiting for connection...') // Listen for connection event this.relayHub.on('connected', () => { console.log('💬 RelayHub connected, setting up message subscription...') this.setupMessageSubscription() }) // Also retry after timeout in case event is missed setTimeout(() => this.setupMessageSubscription(), 5000) return } if (!userPubkey) { console.warn('💬 No user pubkey available for subscription') setTimeout(() => this.setupMessageSubscription(), 2000) return } // Subscribe to encrypted direct messages (kind 4) addressed to this user this.subscriptionUnsubscriber = this.relayHub.subscribe({ id: 'chat-messages', filters: [ { kinds: [4], // Encrypted direct messages '#p': [userPubkey] // Messages tagged with our pubkey } ], onEvent: async (event: Event) => { // Skip our own messages if (event.pubkey === userPubkey) { return } await this.processIncomingMessage(event) }, onEose: () => { console.log('💬 Chat message subscription EOSE received') } }) console.log('💬 Chat message subscription set up successfully for pubkey:', userPubkey.substring(0, 10) + '...') } catch (error) { console.error('💬 Failed to setup message subscription:', error) // Retry after delay setTimeout(() => this.setupMessageSubscription(), 3000) } } /** * Register with VisibilityService for connection management */ private registerWithVisibilityService(): void { if (!this.visibilityService) { this.debug('VisibilityService not available') return } this.visibilityUnsubscribe = this.visibilityService.registerService( this.metadata.name, async () => this.handleAppResume(), async () => this.handleAppPause() ) this.debug('Registered with VisibilityService') } /** * Handle app resuming from visibility change */ private async handleAppResume(): Promise { this.debug('App resumed - checking chat connections') // Check if subscription is still active if (!this.subscriptionUnsubscriber) { this.debug('Chat subscription lost, re-establishing...') this.setupMessageSubscription() } // Check if we need to sync missed messages await this.syncMissedMessages() } /** * Handle app pausing from visibility change */ private async handleAppPause(): Promise { this.debug('App paused - chat subscription will be maintained for quick resume') // Don't immediately unsubscribe - let RelayHub handle connection management // Subscriptions will be restored automatically on resume if needed } /** * Sync any messages that might have been missed while app was hidden */ private async syncMissedMessages(): Promise { try { // For each peer, try to load recent messages const peers = Array.from(this.peers.value.values()) const syncPromises = peers.map(peer => this.loadRecentMessagesForPeer(peer.pubkey)) await Promise.allSettled(syncPromises) this.debug('Missed messages sync completed') } catch (error) { console.warn('Failed to sync missed messages:', error) } } /** * Process an incoming message event */ private async processIncomingMessage(event: any): Promise { try { // Check both injected auth service AND global auth composable const { auth } = await import('@/composables/useAuth') const hasAuthService = this.authService?.user?.value?.pubkey const hasGlobalAuth = auth.currentUser.value?.pubkey const userPubkey = hasAuthService ? this.authService.user.value.pubkey : auth.currentUser.value?.pubkey const userPrivkey = hasAuthService ? this.authService.user.value.prvkey : auth.currentUser.value?.prvkey if (!userPubkey || !userPrivkey) { console.warn('Cannot process message: user not authenticated') return } // Get sender pubkey from event const senderPubkey = event.pubkey // Decrypt the message content const decryptedContent = await nip04.decrypt(userPrivkey, senderPubkey, event.content) // Check if this is a market-related message let isMarketMessage = false try { const parsedContent = JSON.parse(decryptedContent) if (parsedContent.type === 1 || parsedContent.type === 2) { // This is a market order message isMarketMessage = true // Forward to market handler if (this.marketMessageHandler) { await this.marketMessageHandler(event) console.log('💬 Market message forwarded to market handler and will also be added to chat') } else { console.warn('Market message handler not available, message will be treated as chat') } } } catch (e) { // Not JSON or not a market message, treat as regular chat } // Process as chat message regardless (market messages should also appear in chat) { // Format the content for display based on whether it's a market message let displayContent = decryptedContent if (isMarketMessage) { try { const parsedContent = JSON.parse(decryptedContent) if (parsedContent.type === 1) { // Payment request displayContent = `💰 Payment Request for Order ${parsedContent.id}\n${parsedContent.message || 'Please pay to proceed with your order'}` } else if (parsedContent.type === 2) { // Order status update const status = [] if (parsedContent.paid === true) status.push('✅ Paid') else if (parsedContent.paid === false) status.push('⏳ Payment Pending') if (parsedContent.shipped === true) status.push('📦 Shipped') else if (parsedContent.shipped === false) status.push('🔄 Processing') displayContent = `📋 Order Update: ${parsedContent.id}\n${status.join(' | ')}\n${parsedContent.message || ''}` } } catch (e) { // Fallback to raw content if parsing fails } } // Create a chat message const message: ChatMessage = { id: event.id, content: displayContent, created_at: event.created_at, sent: false, pubkey: senderPubkey } // Ensure we have a peer record for the sender this.addPeer(senderPubkey) // Add the message this.addMessage(senderPubkey, message) console.log('Received encrypted chat message from:', senderPubkey.slice(0, 8)) } } catch (error) { console.error('Failed to process incoming message:', error) } } /** * Load recent messages for a specific peer */ private async loadRecentMessagesForPeer(peerPubkey: string): Promise { // Check both injected auth service AND global auth composable const { auth } = await import('@/composables/useAuth') const hasAuthService = this.authService?.user?.value?.pubkey const hasGlobalAuth = auth.currentUser.value?.pubkey const userPubkey = hasAuthService ? this.authService.user.value.pubkey : auth.currentUser.value?.pubkey if (!userPubkey || !this.relayHub) return try { // Get last 10 messages from the last hour for this peer const oneHourAgo = Math.floor(Date.now() / 1000) - 3600 const events = await this.relayHub.queryEvents([ { kinds: [4], // Encrypted DMs authors: [peerPubkey], '#p': [userPubkey], since: oneHourAgo, limit: 10 }, { kinds: [4], // Encrypted DMs authors: [userPubkey], '#p': [peerPubkey], since: oneHourAgo, limit: 10 } ]) // Process any new messages for (const event of events) { await this.processIncomingMessage(event) } } catch (error) { this.debug(`Failed to load recent messages for peer ${peerPubkey.slice(0, 8)}:`, error) } } /** * Cleanup when service is disposed (overrides BaseService) */ protected async onDispose(): Promise { // Clear auth check interval if (this.authCheckInterval) { clearInterval(this.authCheckInterval) this.authCheckInterval = undefined } // Unregister from visibility service if (this.visibilityUnsubscribe) { this.visibilityUnsubscribe() this.visibilityUnsubscribe = undefined } // Unsubscribe from message subscription if (this.subscriptionUnsubscriber) { this.subscriptionUnsubscriber() this.subscriptionUnsubscriber = undefined } this.messages.value.clear() this.peers.value.clear() this.isFullyInitialized = false this.debug('Chat service disposed') } /** * Legacy destroy method for backward compatibility */ destroy(): void { this.dispose() } }