- Simplify imports in app.ts by removing unused SERVICE_TOKENS. - Eliminate the NavigationItem interface in Navbar.vue as its functionality is now managed by useModularNavigation. - Introduce new legacy composable stubs for useNostrChat and useRelayHub, indicating a shift towards modular chat and relay services. - Update MyTicketsPage.vue to correct the import path for useUserTickets, enhancing module organization. - Refactor ChatService to improve type handling for event tags, ensuring better type safety. Remove ChatComponent, useNostrChat composable, and ChatPage for a modular chat architecture - Delete ChatComponent.vue to streamline chat functionality. - Remove legacy useNostrChat composable, transitioning to a more modular chat service approach. - Eliminate ChatPage.vue as part of the refactor to enhance code organization and maintainability.
586 lines
No EOL
18 KiB
TypeScript
586 lines
No EOL
18 KiB
TypeScript
import { ref, computed } from 'vue'
|
|
import { eventBus } from '@/core/event-bus'
|
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
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'
|
|
|
|
const UNREAD_MESSAGES_KEY = 'nostr-chat-unread-messages'
|
|
const PEERS_KEY = 'nostr-chat-peers'
|
|
|
|
export class ChatService {
|
|
private messages = ref<Map<string, ChatMessage[]>>(new Map())
|
|
private peers = ref<Map<string, ChatPeer>>(new Map())
|
|
private config: ChatConfig
|
|
private subscriptionUnsubscriber?: () => void
|
|
private isInitialized = ref(false)
|
|
private marketMessageHandler?: (event: any) => Promise<void>
|
|
|
|
constructor(config: ChatConfig) {
|
|
this.config = config
|
|
this.loadPeersFromStorage()
|
|
|
|
// Defer initialization until services are available
|
|
this.deferredInitialization()
|
|
}
|
|
|
|
// Register market message handler for forwarding market-related DMs
|
|
setMarketMessageHandler(handler: (event: any) => Promise<void>) {
|
|
this.marketMessageHandler = handler
|
|
}
|
|
|
|
// Defer initialization until services are ready
|
|
private deferredInitialization(): void {
|
|
// Try initialization immediately
|
|
this.tryInitialization()
|
|
|
|
// Also listen for auth events to re-initialize when user logs in
|
|
eventBus.on('auth:login', () => {
|
|
console.log('💬 Auth login detected, initializing chat...')
|
|
this.tryInitialization()
|
|
})
|
|
}
|
|
|
|
// Try to initialize services if they're available
|
|
private async tryInitialization(): Promise<void> {
|
|
try {
|
|
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
|
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
|
|
|
if (!relayHub || !authService?.user?.value?.pubkey) {
|
|
console.log('💬 Services not ready yet, will retry when auth completes...')
|
|
return
|
|
}
|
|
|
|
console.log('💬 Services ready, initializing chat functionality...')
|
|
|
|
// 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()
|
|
|
|
// Mark as initialized
|
|
this.isInitialized.value = true
|
|
console.log('💬 Chat service fully initialized and ready!')
|
|
|
|
} catch (error) {
|
|
console.error('💬 Failed to initialize chat:', error)
|
|
this.isInitialized.value = false
|
|
}
|
|
}
|
|
|
|
// Initialize message handling (subscription + history loading)
|
|
async initializeMessageHandling(): Promise<void> {
|
|
// Set up real-time subscription
|
|
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 computed(() => this.isInitialized.value)
|
|
}
|
|
|
|
// 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> {
|
|
return this.loadPeersFromAPI()
|
|
}
|
|
|
|
// Check if services are available for messaging
|
|
private checkServicesAvailable(): { relayHub: any; authService: any } | null {
|
|
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
|
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
|
|
|
if (!relayHub || !authService?.user?.value?.prvkey) {
|
|
return null
|
|
}
|
|
|
|
if (!relayHub.isConnected) {
|
|
return null
|
|
}
|
|
|
|
return { relayHub, authService }
|
|
}
|
|
|
|
// Send a message
|
|
async sendMessage(peerPubkey: string, content: string): Promise<void> {
|
|
try {
|
|
const services = this.checkServicesAvailable()
|
|
|
|
if (!services) {
|
|
throw new Error('Chat services not ready. Please wait for connection to establish.')
|
|
}
|
|
|
|
const { relayHub, authService } = services
|
|
|
|
const userPrivkey = authService.user.value.prvkey
|
|
const userPubkey = authService.user.value.pubkey
|
|
|
|
// 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 {
|
|
try {
|
|
const stored = localStorage.getItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`)
|
|
if (stored) {
|
|
const data = JSON.parse(stored)
|
|
return {
|
|
...data,
|
|
processedMessageIds: new Set(data.processedMessageIds || [])
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load unread data for peer:', peerPubkey, error)
|
|
}
|
|
|
|
return {
|
|
lastReadTimestamp: 0,
|
|
unreadCount: 0,
|
|
processedMessageIds: new Set()
|
|
}
|
|
}
|
|
|
|
private saveUnreadData(peerPubkey: string, data: UnreadMessageData): void {
|
|
try {
|
|
const serializable = {
|
|
...data,
|
|
processedMessageIds: Array.from(data.processedMessageIds)
|
|
}
|
|
localStorage.setItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`, JSON.stringify(serializable))
|
|
} catch (error) {
|
|
console.warn('Failed to save unread data for peer:', peerPubkey, error)
|
|
}
|
|
}
|
|
|
|
// Load peers from API
|
|
async loadPeersFromAPI(): Promise<void> {
|
|
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()
|
|
|
|
// Clear existing peers and load from API
|
|
this.peers.value.clear()
|
|
|
|
data.forEach((peer: any) => {
|
|
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`)
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load peers from API:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private loadPeersFromStorage(): void {
|
|
try {
|
|
const stored = localStorage.getItem(PEERS_KEY)
|
|
if (stored) {
|
|
const peersArray = JSON.parse(stored) as ChatPeer[]
|
|
peersArray.forEach(peer => {
|
|
this.peers.value.set(peer.pubkey, peer)
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load peers from storage:', error)
|
|
}
|
|
}
|
|
|
|
private savePeersToStorage(): void {
|
|
try {
|
|
const peersArray = Array.from(this.peers.value.values())
|
|
localStorage.setItem(PEERS_KEY, JSON.stringify(peersArray))
|
|
} catch (error) {
|
|
console.warn('Failed to save peers to storage:', error)
|
|
}
|
|
}
|
|
|
|
// Load message history for known peers
|
|
private async loadMessageHistory(): Promise<void> {
|
|
try {
|
|
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
|
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
|
|
|
if (!relayHub || !authService?.user?.value?.pubkey) {
|
|
console.warn('Cannot load message history: missing services')
|
|
return
|
|
}
|
|
|
|
const userPubkey = authService.user.value.pubkey
|
|
const userPrivkey = authService.user.value.prvkey
|
|
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 relayHub.queryEvents([
|
|
{
|
|
kinds: [4],
|
|
authors: peerPubkeys, // Messages from peers
|
|
'#p': [userPubkey], // Messages tagged to us
|
|
limit: 100
|
|
}
|
|
])
|
|
|
|
const sentEvents = await 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 setupMessageSubscription(): void {
|
|
try {
|
|
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
|
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
|
|
|
if (!relayHub || !authService?.user?.value?.pubkey) {
|
|
console.warn('💬 Cannot setup message subscription: missing services')
|
|
return
|
|
}
|
|
|
|
if (!relayHub.isConnected) {
|
|
console.warn('💬 RelayHub not connected, waiting for connection...')
|
|
// Listen for connection event
|
|
relayHub.on('connected', () => {
|
|
console.log('💬 RelayHub connected, setting up message subscription...')
|
|
this.setupMessageSubscription()
|
|
})
|
|
return
|
|
}
|
|
|
|
const userPubkey = authService.user.value.pubkey
|
|
const userPrivkey = authService.user.value.prvkey
|
|
|
|
// Subscribe to encrypted direct messages (kind 4) addressed to this user
|
|
this.subscriptionUnsubscriber = relayHub.subscribe({
|
|
id: 'chat-messages',
|
|
filters: [
|
|
{
|
|
kinds: [4], // Encrypted direct messages
|
|
'#p': [userPubkey] // Messages tagged with our pubkey
|
|
}
|
|
],
|
|
onEvent: async (event: Event) => {
|
|
try {
|
|
// Find the sender's pubkey from the event
|
|
const senderPubkey = event.pubkey
|
|
|
|
// Skip our own messages
|
|
if (senderPubkey === userPubkey) {
|
|
return
|
|
}
|
|
|
|
// Decrypt the message
|
|
const decryptedContent = await nip04.decrypt(userPrivkey, senderPubkey, event.content)
|
|
|
|
// Check if this is a market-related message (JSON with type field)
|
|
let isMarketMessage = false
|
|
try {
|
|
const parsedContent = JSON.parse(decryptedContent)
|
|
if (parsedContent && typeof parsedContent.type === 'number' && (parsedContent.type === 1 || parsedContent.type === 2)) {
|
|
// This is a market message (payment request type 1 or status update type 2)
|
|
isMarketMessage = true
|
|
console.log('🛒 Forwarding market message to market handler:', parsedContent.type)
|
|
|
|
// 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
|
|
}
|
|
|
|
// Only process as chat message if it's not a market message
|
|
if (!isMarketMessage) {
|
|
// Create a chat message
|
|
const message: ChatMessage = {
|
|
id: event.id,
|
|
content: decryptedContent,
|
|
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 decrypt incoming message:', error)
|
|
}
|
|
},
|
|
onEose: () => {
|
|
console.log('Chat message subscription EOSE received')
|
|
}
|
|
})
|
|
|
|
console.log('Chat message subscription set up successfully')
|
|
|
|
} catch (error) {
|
|
console.error('Failed to setup message subscription:', error)
|
|
}
|
|
}
|
|
|
|
// Cleanup
|
|
destroy(): void {
|
|
// Unsubscribe from message subscription
|
|
if (this.subscriptionUnsubscriber) {
|
|
this.subscriptionUnsubscriber()
|
|
}
|
|
|
|
this.messages.value.clear()
|
|
this.peers.value.clear()
|
|
}
|
|
} |