web-app/src/modules/chat/services/chat-service.ts
padreug 5a899d1501 Enhance ChatService to process market messages in chat
- Updated ChatService to forward market messages to the chat interface, ensuring they are displayed alongside regular chat messages.
- Improved message formatting for payment requests and order status updates, providing clearer user notifications.
- Deprecated the previous order updates subscription method in useMarket, redirecting functionality to the chat service for better integration.

These changes improve the user experience by consolidating message handling and enhancing clarity in communication.
2025-09-07 00:08:38 +02:00

861 lines
No EOL
28 KiB
TypeScript

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<Map<string, ChatMessage[]>>(new Map())
private peers = ref<Map<string, ChatPeer>>(new Map())
private config: ChatConfig
private subscriptionUnsubscriber?: () => void
private marketMessageHandler?: (event: any) => Promise<void>
private visibilityUnsubscribe?: () => void
private isFullyInitialized = false
private authCheckInterval?: ReturnType<typeof setInterval>
constructor(config: ChatConfig) {
super()
this.config = config
this.loadPeersFromStorage()
}
// Register market message handler for forwarding market-related DMs
setMarketMessageHandler(handler: (event: any) => Promise<void>) {
this.marketMessageHandler = handler
}
/**
* Service-specific initialization (called by BaseService)
*/
protected async onInitialize(): Promise<void> {
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<void> {
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<void> {
// 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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
// 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()
}
}