Enhance VisibilityService integration for WebSocket and chat services
- Add detailed examples for WebSocket connection recovery and chat message synchronization in the VisibilityService documentation. - Refactor ChatService to register with VisibilityService, enabling automatic handling of app visibility changes and missed message synchronization. - Implement connection recovery logic in NostrclientHub and ChatService to ensure seamless user experience during app backgrounding. - Update base module to ensure proper initialization of services with VisibilityService dependencies, enhancing overall connection management.
This commit is contained in:
parent
d03a1fcd2c
commit
ef7333e68e
5 changed files with 756 additions and 121 deletions
|
|
@ -56,6 +56,10 @@ export const baseModule: ModulePlugin = {
|
|||
waitForDependencies: false, // VisibilityService has no dependencies
|
||||
maxRetries: 1
|
||||
})
|
||||
await nostrclientHub.initialize({
|
||||
waitForDependencies: true, // NostrClientHub depends on VisibilityService
|
||||
maxRetries: 3
|
||||
})
|
||||
|
||||
console.log('✅ Base module installed successfully')
|
||||
},
|
||||
|
|
@ -63,9 +67,12 @@ export const baseModule: ModulePlugin = {
|
|||
async uninstall() {
|
||||
console.log('🗑️ Uninstalling base module...')
|
||||
|
||||
// Cleanup Nostr connections
|
||||
relayHub.disconnect()
|
||||
nostrclientHub.disconnect?.()
|
||||
// Cleanup services
|
||||
await relayHub.dispose()
|
||||
await nostrclientHub.dispose()
|
||||
await auth.dispose()
|
||||
await paymentService.dispose()
|
||||
await visibilityService.dispose()
|
||||
|
||||
console.log('✅ Base module uninstalled')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,30 +1,6 @@
|
|||
import type { Filter, Event } from 'nostr-tools'
|
||||
import { BaseService } from '@/core/base/BaseService'
|
||||
|
||||
// Simple EventEmitter for browser compatibility
|
||||
class EventEmitter {
|
||||
private events: { [key: string]: Function[] } = {}
|
||||
|
||||
on(event: string, listener: Function) {
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
this.events[event].push(listener)
|
||||
}
|
||||
|
||||
emit(event: string, ...args: any[]) {
|
||||
if (this.events[event]) {
|
||||
this.events[event].forEach(listener => listener(...args))
|
||||
}
|
||||
}
|
||||
|
||||
removeAllListeners(event?: string) {
|
||||
if (event) {
|
||||
delete this.events[event]
|
||||
} else {
|
||||
this.events = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NostrclientConfig {
|
||||
url: string
|
||||
|
|
@ -46,7 +22,18 @@ export interface RelayStatus {
|
|||
error?: string
|
||||
}
|
||||
|
||||
export class NostrclientHub extends EventEmitter {
|
||||
export class NostrclientHub extends BaseService {
|
||||
// Service metadata
|
||||
protected readonly metadata = {
|
||||
name: 'NostrclientHub',
|
||||
version: '1.0.0',
|
||||
dependencies: ['VisibilityService']
|
||||
}
|
||||
|
||||
// EventEmitter functionality
|
||||
private events: { [key: string]: Function[] } = {}
|
||||
|
||||
// Service state
|
||||
private ws: WebSocket | null = null
|
||||
private config: NostrclientConfig
|
||||
private subscriptions: Map<string, SubscriptionConfig> = new Map()
|
||||
|
|
@ -54,6 +41,7 @@ export class NostrclientHub extends EventEmitter {
|
|||
private reconnectAttempts = 0
|
||||
private readonly maxReconnectAttempts = 5
|
||||
private readonly reconnectDelay = 5000
|
||||
private visibilityUnsubscribe?: () => void
|
||||
|
||||
// Connection state
|
||||
private _isConnected = false
|
||||
|
|
@ -64,6 +52,42 @@ export class NostrclientHub extends EventEmitter {
|
|||
this.config = config
|
||||
}
|
||||
|
||||
// EventEmitter methods
|
||||
on(event: string, listener: Function) {
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
this.events[event].push(listener)
|
||||
}
|
||||
|
||||
emit(event: string, ...args: any[]) {
|
||||
if (this.events[event]) {
|
||||
this.events[event].forEach(listener => listener(...args))
|
||||
}
|
||||
}
|
||||
|
||||
removeAllListeners(event?: string) {
|
||||
if (event) {
|
||||
delete this.events[event]
|
||||
} else {
|
||||
this.events = {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service-specific initialization (called by BaseService)
|
||||
*/
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// Connect to WebSocket
|
||||
console.log('🔧 NostrclientHub: Initializing connection to', this.config.url)
|
||||
await this.connect()
|
||||
|
||||
// Register with visibility service
|
||||
this.registerWithVisibilityService()
|
||||
|
||||
this.debug('NostrclientHub initialized')
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._isConnected
|
||||
}
|
||||
|
|
@ -83,13 +107,6 @@ export class NostrclientHub extends EventEmitter {
|
|||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and connect to nostrclient WebSocket
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
console.log('🔧 NostrclientHub: Initializing connection to', this.config.url)
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the nostrclient WebSocket
|
||||
|
|
@ -351,6 +368,69 @@ export class NostrclientHub extends EventEmitter {
|
|||
await this.connect()
|
||||
}, delay) as unknown as number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 nostrclient WebSocket connection')
|
||||
|
||||
// Check if we need to reconnect
|
||||
if (!this.isConnected && !this._isConnecting) {
|
||||
this.debug('WebSocket disconnected, attempting to reconnect...')
|
||||
await this.connect()
|
||||
} else if (this.isConnected) {
|
||||
// Connection is alive, resubscribe to ensure all subscriptions are active
|
||||
this.resubscribeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app pausing from visibility change
|
||||
*/
|
||||
private async handleAppPause(): Promise<void> {
|
||||
this.debug('App paused - WebSocket connection will be maintained for quick resume')
|
||||
|
||||
// Don't immediately disconnect - WebSocket will be checked on resume
|
||||
// This allows for quick resume without full reconnection overhead
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when service is disposed (overrides BaseService)
|
||||
*/
|
||||
protected async onDispose(): Promise<void> {
|
||||
// Unregister from visibility service
|
||||
if (this.visibilityUnsubscribe) {
|
||||
this.visibilityUnsubscribe()
|
||||
this.visibilityUnsubscribe = undefined
|
||||
}
|
||||
|
||||
// Disconnect WebSocket
|
||||
this.disconnect()
|
||||
|
||||
// Clear all event listeners
|
||||
this.removeAllListeners()
|
||||
|
||||
this.debug('NostrclientHub disposed')
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export class ChatService extends BaseService {
|
|||
protected readonly metadata = {
|
||||
name: 'ChatService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['RelayHub', 'AuthService']
|
||||
dependencies: ['RelayHub', 'AuthService', 'VisibilityService']
|
||||
}
|
||||
|
||||
// Service-specific state
|
||||
|
|
@ -23,6 +23,7 @@ export class ChatService extends BaseService {
|
|||
private config: ChatConfig
|
||||
private subscriptionUnsubscriber?: () => void
|
||||
private marketMessageHandler?: (event: any) => Promise<void>
|
||||
private visibilityUnsubscribe?: () => void
|
||||
|
||||
constructor(config: ChatConfig) {
|
||||
super()
|
||||
|
|
@ -71,6 +72,9 @@ export class ChatService extends BaseService {
|
|||
// Initialize message handling (subscription + history loading)
|
||||
await this.initializeMessageHandling()
|
||||
|
||||
// Register with visibility service
|
||||
this.registerWithVisibilityService()
|
||||
|
||||
this.debug('Chat service fully initialized and ready!')
|
||||
}
|
||||
|
||||
|
|
@ -491,7 +495,6 @@ export class ChatService extends BaseService {
|
|||
}
|
||||
|
||||
const userPubkey = this.authService.user.value.pubkey
|
||||
const userPrivkey = this.authService.user.value.prvkey
|
||||
|
||||
// Subscribe to encrypted direct messages (kind 4) addressed to this user
|
||||
this.subscriptionUnsubscriber = this.relayHub.subscribe({
|
||||
|
|
@ -503,61 +506,12 @@ export class ChatService extends BaseService {
|
|||
}
|
||||
],
|
||||
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)
|
||||
// Skip our own messages
|
||||
if (event.pubkey === userPubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.processIncomingMessage(event)
|
||||
},
|
||||
onEose: () => {
|
||||
console.log('Chat message subscription EOSE received')
|
||||
|
|
@ -571,10 +525,178 @@ export class ChatService extends BaseService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 process incoming message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load recent messages for a specific peer
|
||||
*/
|
||||
private async loadRecentMessagesForPeer(peerPubkey: string): Promise<void> {
|
||||
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> {
|
||||
// Unregister from visibility service
|
||||
if (this.visibilityUnsubscribe) {
|
||||
this.visibilityUnsubscribe()
|
||||
this.visibilityUnsubscribe = undefined
|
||||
}
|
||||
|
||||
// Unsubscribe from message subscription
|
||||
if (this.subscriptionUnsubscriber) {
|
||||
this.subscriptionUnsubscriber()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue