Key changes:
- Add notification store with path-based wildcard support (chat/*, chat/{pubkey}, *)
- Remove UnreadMessageData interface and processedMessageIds Set tracking
- Implement timestamp-based "seen at" logic with wildcard matching
- Add markAllChatsAsRead() for batch operations
- Integrate ChatNotificationConfig for module configuration
- Create useNotifications composable for easy notification access
Benefits:
- Simpler architecture (removed processedMessageIds complexity)
- Flexible wildcard-based "mark as read" operations
- Future-proof for Primal-style backend sync
- User-scoped storage via StorageService
- Clean separation of concerns
refactor: enhance chat service with activity tracking and sorting
- Updated the ChatService to track lastSent, lastReceived, and lastChecked timestamps for peers, improving message handling and user experience.
- Implemented sorting of peers by last activity to prioritize recent conversations.
- Adjusted message handling to update peer activity based on message direction.
- Ensured updated peer data is saved to storage after modifications.
These changes streamline chat interactions and enhance the overall functionality of the chat service.
refactor: improve ChatService and notification store initialization
- Updated ChatService to ensure the notification store is initialized only after user authentication, preventing potential errors.
- Introduced a new method to safely access the notification store, enhancing error handling.
- Enhanced peer activity tracking by calculating last activity based on actual message timestamps, improving sorting and user experience.
- Added debounced saving of notification state to storage, optimizing performance and reducing unnecessary writes.
- Improved logging for better debugging and visibility into notification handling processes.
These changes enhance the reliability and efficiency of the chat service and notification management.
refactor: clean up logging in ChatService and notification store
- Removed unnecessary console logs from ChatService to streamline the code and improve performance.
- Simplified the initialization process of the notification store by eliminating redundant logging statements.
- Enhanced readability and maintainability of the code by focusing on essential operations without excessive debug output.
These changes contribute to a cleaner codebase and improved performance in chat service operations.
FIX BUILD ERRORS
refactor: update chat module notification configuration
- Refactored the notification settings in the chat module to use a nested structure, enhancing clarity and organization.
- Introduced `wildcardSupport` to the notification configuration, allowing for more flexible notification handling.
- Maintained existing functionality while improving the overall configuration structure.
These changes contribute to a more maintainable and extensible chat module configuration.
refactor: optimize ChatComponent and ChatService for improved performance
- Removed unnecessary sorting of peers in ChatComponent, leveraging the existing order provided by the chat service.
- Updated the useFuzzySearch composable to directly utilize the sorted peers, enhancing search efficiency.
- Cleaned up logging in ChatService by removing redundant console statements, streamlining the codebase.
- Added critical checks to prevent the current user from being included in peer interactions, improving user experience and functionality.
These changes contribute to a more efficient and maintainable chat module.
refactor: simplify message publishing in ChatService
- Removed unnecessary variable assignment in the message publishing process, directly awaiting the relayHub.publishEvent call.
- This change streamlines the code and enhances readability without altering functionality.
These modifications contribute to a cleaner and more efficient chat service implementation.
844 lines
No EOL
31 KiB
TypeScript
844 lines
No EOL
31 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, 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<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>
|
|
private notificationStore?: ReturnType<typeof useChatNotificationStore>
|
|
|
|
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<void>) {
|
|
this.marketMessageHandler = handler
|
|
}
|
|
|
|
/**
|
|
* Get the notification store, ensuring it's initialized
|
|
* CRITICAL: This must only be called after onInitialize() has run
|
|
*/
|
|
private getNotificationStore(): ReturnType<typeof useChatNotificationStore> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
// 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<void> {
|
|
// 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<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 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<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')
|
|
}
|
|
|
|
// 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<void> {
|
|
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<string>()
|
|
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<void> {
|
|
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<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
|
|
// 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<void> {
|
|
// 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<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()
|
|
}
|
|
} |