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, ChatConfig } from '../types' import { getAuthToken } from '@/lib/config/lnbits' import { config } from '@/lib/config' import { useChatNotificationStore } from '../stores/notification' 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 private notificationStore?: ReturnType constructor(config: ChatConfig) { super() this.config = config // NOTE: DO NOT call loadPeersFromStorage() here - it depends on StorageService // which may not be available yet. Moved to onInitialize(). } // Register market message handler for forwarding market-related DMs setMarketMessageHandler(handler: (event: any) => Promise) { this.marketMessageHandler = handler } /** * Get the notification store, ensuring it's initialized * CRITICAL: This must only be called after onInitialize() has run */ private getNotificationStore(): ReturnType { if (!this.notificationStore) { throw new Error('ChatService: Notification store not initialized yet. This should not happen after onInitialize().') } return this.notificationStore } /** * 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 // Removed dual auth import const hasAuthService = this.authService?.user?.value?.pubkey this.debug('Auth detection:', { hasAuthService: !!hasAuthService, authServicePubkey: hasAuthService ? hasAuthService.substring(0, 10) : 'none', }) if (!this.authService?.user?.value) { 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 () => { // Removed dual auth import if (this.authService?.user?.value?.pubkey) { 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...') // CRITICAL: Initialize notification store AFTER user is authenticated // StorageService needs user pubkey to scope the storage keys correctly if (!this.notificationStore) { this.notificationStore = useChatNotificationStore() } // 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!') } // 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(() => { const peers = Array.from(this.peers.value.values()) // Sort by last activity (Coracle pattern) // Most recent conversation first return peers.sort((a, b) => { // Calculate activity from actual messages (source of truth) const aMessages = this.getMessages(a.pubkey) const bMessages = this.getMessages(b.pubkey) let aActivity = 0 let bActivity = 0 // Get last message timestamp from actual messages if (aMessages.length > 0) { const lastMsg = aMessages[aMessages.length - 1] aActivity = lastMsg.created_at } else { // Fallback to stored timestamps only if no messages aActivity = Math.max(a.lastSent || 0, a.lastReceived || 0) } if (bMessages.length > 0) { const lastMsg = bMessages[bMessages.length - 1] bActivity = lastMsg.created_at } else { // Fallback to stored timestamps only if no messages bActivity = Math.max(b.lastSent || 0, b.lastReceived || 0) } // Peers with activity always come before peers without activity if (aActivity > 0 && bActivity === 0) return -1 if (aActivity === 0 && bActivity > 0) return 1 // Primary sort: by activity timestamp (descending - most recent first) if (bActivity !== aActivity) { return bActivity - aActivity } // Stable tiebreaker: sort by pubkey (prevents random reordering) return a.pubkey.localeCompare(b.pubkey) }) }) } get totalUnreadCount() { return computed(() => { if (!this.notificationStore) return 0 // Not initialized yet return Array.from(this.peers.value.values()) .reduce((total, peer) => { const messages = this.getMessages(peer.pubkey) const unreadCount = this.getNotificationStore().getUnreadCount(peer.pubkey, messages) return total + 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 { const peer = this.peers.value.get(pubkey) if (peer && this.notificationStore) { // Update unread count from notification store (only if store is initialized) const messages = this.getMessages(pubkey) peer.unreadCount = this.getNotificationStore().getUnreadCount(pubkey, messages) } return peer } // 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, lastSent: 0, lastReceived: 0, lastChecked: 0 } 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 (ascending - chronological order within conversation) 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 // Update lastSent or lastReceived based on message direction (Coracle pattern) if (message.sent) { peer.lastSent = Math.max(peer.lastSent, message.created_at) } else { peer.lastReceived = Math.max(peer.lastReceived, message.created_at) } // Update unread count from notification store (only if store is initialized) const messages = this.getMessages(peerPubkey) const unreadCount = this.notificationStore ? this.getNotificationStore().getUnreadCount(peerPubkey, messages) : 0 peer.unreadCount = unreadCount // Save updated peer data this.savePeersToStorage() // Emit events const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received' eventBus.emit(eventType, { message, peerPubkey }, 'chat-service') // Emit unread count change if message is not sent by us if (!message.sent) { eventBus.emit('chat:unread-count-changed', { peerPubkey, count: unreadCount, totalUnread: this.totalUnreadCount.value }, 'chat-service') } } } // Mark messages as read for a peer markAsRead(peerPubkey: string, timestamp?: number): void { const peer = this.peers.value.get(peerPubkey) if (peer) { const ts = timestamp || Math.floor(Date.now() / 1000) // Update lastChecked timestamp (Coracle pattern) const oldChecked = peer.lastChecked peer.lastChecked = Math.max(peer.lastChecked, ts) // Use notification store to mark as read this.getNotificationStore().markChatAsRead(peerPubkey, timestamp) // Update peer unread count const messages = this.getMessages(peerPubkey) const oldUnreadCount = peer.unreadCount peer.unreadCount = this.getNotificationStore().getUnreadCount(peerPubkey, messages) // Only save if something actually changed (prevent unnecessary reactivity) if (oldChecked !== peer.lastChecked || oldUnreadCount !== peer.unreadCount) { this.savePeersToStorage() } // Emit event only if unread count changed if (oldUnreadCount !== peer.unreadCount) { eventBus.emit('chat:unread-count-changed', { peerPubkey, count: peer.unreadCount, totalUnread: this.totalUnreadCount.value }, 'chat-service') } } } // Mark all chats as read markAllChatsAsRead(): void { this.getNotificationStore().markAllChatsAsRead() // Update all peers' unread counts Array.from(this.peers.value.values()).forEach(peer => { const messages = this.getMessages(peer.pubkey) peer.unreadCount = this.getNotificationStore().getUnreadCount(peer.pubkey, messages) }) // Emit event eventBus.emit('chat:unread-count-changed', { peerPubkey: '*', count: 0, totalUnread: 0 }, 'chat-service') } // Refresh peers from API async refreshPeers(): Promise { // Check if we should trigger full initialization // Removed dual auth import const hasAuth = this.authService?.user?.value?.pubkey if (!this.isFullyInitialized && hasAuth) { 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 // Removed dual auth import const userPubkey = this.authService?.user?.value?.pubkey const userPrivkey = this.authService?.user?.value?.prvkey if (!this.relayHub || (!this.authService?.user?.value)) { return null } if (!this.relayHub.isConnected) { return null } return { relayHub: this.relayHub, authService: this.authService, 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 privkeyBytes = this.hexToUint8Array(userPrivkey) const signedEvent = finalizeEvent(eventTemplate, privkeyBytes) // 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 await relayHub.publishEvent(signedEvent) } catch (error) { console.error('Failed to send message:', error) throw error } } // Private methods /** * Convert hex string to Uint8Array (browser-compatible) */ private hexToUint8Array(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2) for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substr(i, 2), 16) } return bytes } // 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') } // Get current user pubkey to exclude from peers const currentUserPubkey = this.authService?.user?.value?.pubkey if (!currentUserPubkey) { console.warn('💬 No current user pubkey available') } 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) { 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() 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 } // CRITICAL: Skip current user - you can't chat with yourself! if (currentUserPubkey && peer.pubkey === currentUserPubkey) { return } // Check if peer already exists to preserve message history timestamps const existingPeer = this.peers.value.get(peer.pubkey) if (existingPeer) { // Update name only if provided if (peer.username && peer.username !== existingPeer.name) { existingPeer.name = peer.username } } else { // Create new peer with all required fields const chatPeer: ChatPeer = { pubkey: peer.pubkey, name: peer.username || `User ${peer.pubkey.slice(0, 8)}`, unreadCount: 0, lastSent: 0, lastReceived: 0, lastChecked: 0 } this.peers.value.set(peer.pubkey, chatPeer) } }) // Save to storage this.savePeersToStorage() } 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[] peersArray.forEach(peer => { // Migrate old peer structure to new structure with required fields const migratedPeer: ChatPeer = { ...peer, lastSent: peer.lastSent ?? 0, lastReceived: peer.lastReceived ?? 0, lastChecked: peer.lastChecked ?? 0 } this.peers.value.set(peer.pubkey, migratedPeer) }) } 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 // Removed dual auth import // const hasAuthService = this.authService?.user?.value?.pubkey const userPubkey = this.authService?.user?.value?.pubkey const userPrivkey = this.authService?.user?.value?.prvkey if (!this.relayHub || (!this.authService?.user?.value)) { 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) { return } // 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 // CRITICAL: First pass - create all peers from message events BEFORE loading from API const uniquePeerPubkeys = new Set() for (const event of events) { const isFromUs = event.pubkey === userPubkey const peerPubkey = isFromUs ? event.tags.find((tag: string[]) => tag[0] === 'p')?.[1] : event.pubkey if (peerPubkey && peerPubkey !== userPubkey) { uniquePeerPubkeys.add(peerPubkey) } } // Create peers from actual message senders for (const peerPubkey of uniquePeerPubkeys) { if (!this.peers.value.has(peerPubkey)) { this.addPeer(peerPubkey) } } // 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) } } } 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 // Removed dual auth import // const hasAuthService = this.authService?.user?.value?.pubkey const userPubkey = this.authService?.user?.value?.pubkey this.debug('Setup message subscription auth check:', { hasAuthService: !!this.authService?.user?.value, hasRelayHub: !!this.relayHub, relayHubConnected: this.relayHub?.isConnected, userPubkey: userPubkey ? userPubkey.substring(0, 10) : 'none', }) if (!this.relayHub || (!this.authService?.user?.value)) { 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', () => { 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: () => { } }) } 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 // Removed dual auth import // const hasAuthService = this.authService?.user?.value?.pubkey const userPubkey = this.authService?.user?.value?.pubkey const userPrivkey = this.authService?.user?.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) } 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) } } 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 // Removed dual auth import const userPubkey = this.authService?.user?.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() } }