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
|
|
@ -424,6 +424,219 @@ it('should handle app resume correctly', async () => {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## WebSocket Connection Recovery Examples
|
||||||
|
|
||||||
|
### Real-World Chat Message Recovery
|
||||||
|
|
||||||
|
**Scenario**: User gets a phone call, returns to app 5 minutes later. Chat messages arrived while away.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ChatService extends BaseService {
|
||||||
|
private async handleAppResume(): Promise<void> {
|
||||||
|
// Step 1: Check if subscription still exists
|
||||||
|
if (!this.subscriptionUnsubscriber) {
|
||||||
|
this.debug('Chat subscription lost during backgrounding - recreating')
|
||||||
|
this.setupMessageSubscription()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Sync missed messages from all chat peers
|
||||||
|
await this.syncMissedMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncMissedMessages(): Promise<void> {
|
||||||
|
const peers = Array.from(this.peers.value.values())
|
||||||
|
|
||||||
|
for (const peer of peers) {
|
||||||
|
try {
|
||||||
|
// Get messages from last hour for this peer
|
||||||
|
const recentEvents = await this.relayHub.queryEvents([
|
||||||
|
{
|
||||||
|
kinds: [4], // Encrypted DMs
|
||||||
|
authors: [peer.pubkey],
|
||||||
|
'#p': [this.getUserPubkey()],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 3600,
|
||||||
|
limit: 20
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// Process each recovered message
|
||||||
|
for (const event of recentEvents) {
|
||||||
|
await this.processIncomingMessage(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debug(`Recovered ${recentEvents.length} messages from ${peer.pubkey.slice(0, 8)}`)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to recover messages from peer ${peer.pubkey.slice(0, 8)}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nostr Relay Connection Recovery
|
||||||
|
|
||||||
|
**Scenario**: Nostr relays disconnected due to mobile browser suspension. Subscriptions need restoration.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class RelayHub extends BaseService {
|
||||||
|
private async handleResume(): Promise<void> {
|
||||||
|
// Step 1: Check which relays are still connected
|
||||||
|
const disconnectedRelays = this.checkDisconnectedRelays()
|
||||||
|
|
||||||
|
if (disconnectedRelays.length > 0) {
|
||||||
|
this.debug(`Found ${disconnectedRelays.length} disconnected relays`)
|
||||||
|
await this.reconnectToRelays(disconnectedRelays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Restore all subscriptions on recovered relays
|
||||||
|
await this.restoreSubscriptions()
|
||||||
|
|
||||||
|
this.emit('connectionRecovered', {
|
||||||
|
reconnectedRelays: disconnectedRelays.length,
|
||||||
|
restoredSubscriptions: this.subscriptions.size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreSubscriptions(): Promise<void> {
|
||||||
|
if (this.subscriptions.size === 0) return
|
||||||
|
|
||||||
|
this.debug(`Restoring ${this.subscriptions.size} subscriptions`)
|
||||||
|
|
||||||
|
for (const [id, config] of this.subscriptions) {
|
||||||
|
try {
|
||||||
|
// Recreate subscription on available relays
|
||||||
|
const subscription = this.pool.subscribeMany(
|
||||||
|
this.getAvailableRelays(),
|
||||||
|
config.filters,
|
||||||
|
{
|
||||||
|
onevent: (event) => this.emit('event', { subscriptionId: id, event }),
|
||||||
|
oneose: () => this.emit('eose', { subscriptionId: id })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.activeSubscriptions.set(id, subscription)
|
||||||
|
this.debug(`✅ Restored subscription: ${id}`)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.debug(`❌ Failed to restore subscription ${id}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Service Recovery
|
||||||
|
|
||||||
|
**Scenario**: Custom WebSocket service (like nostrclient-hub) needs reconnection after suspension.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class WebSocketService extends BaseService {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private subscriptions = new Map<string, any>()
|
||||||
|
|
||||||
|
private async handleAppResume(): Promise<void> {
|
||||||
|
// Step 1: Check WebSocket connection state
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
this.debug('WebSocket connection lost, reconnecting...')
|
||||||
|
await this.reconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Resubscribe to all active subscriptions
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
await this.resubscribeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reconnect(): Promise<void> {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.ws = new WebSocket(this.config.url)
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.debug('WebSocket reconnected successfully')
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
this.debug('WebSocket reconnection failed:', error)
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = (message) => {
|
||||||
|
this.handleMessage(JSON.parse(message.data))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resubscribeAll(): Promise<void> {
|
||||||
|
for (const [id, config] of this.subscriptions) {
|
||||||
|
const subscribeMessage = JSON.stringify(['REQ', id, ...config.filters])
|
||||||
|
this.ws?.send(subscribeMessage)
|
||||||
|
this.debug(`Resubscribed to: ${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging Connection Issues
|
||||||
|
|
||||||
|
### Enable Debug Logging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In browser console or service initialization
|
||||||
|
localStorage.setItem('debug', 'VisibilityService,RelayHub,ChatService')
|
||||||
|
|
||||||
|
// Or programmatically in your service
|
||||||
|
protected async onInitialize(): Promise<void> {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
this.visibilityService.on('debug', (message, data) => {
|
||||||
|
console.log(`[VisibilityService] ${message}`, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Connection Status
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In browser console
|
||||||
|
// Check visibility state
|
||||||
|
console.log('Visibility state:', visibilityService.getState())
|
||||||
|
|
||||||
|
// Check relay connections
|
||||||
|
console.log('Relay status:', relayHub.getConnectionStatus())
|
||||||
|
|
||||||
|
// Check active subscriptions
|
||||||
|
console.log('Active subscriptions:', relayHub.subscriptionDetails)
|
||||||
|
|
||||||
|
// Force connection check
|
||||||
|
await visibilityService.forceConnectionCheck()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitor Recovery Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class MyService extends BaseService {
|
||||||
|
protected async onInitialize(): Promise<void> {
|
||||||
|
// Listen for visibility events
|
||||||
|
this.visibilityService.on('visibilityChanged', (isVisible) => {
|
||||||
|
console.log('App visibility changed:', isVisible)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for reconnection events
|
||||||
|
this.relayHub.on('connectionRecovered', (data) => {
|
||||||
|
console.log('Connections recovered:', data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
This integration guide provides everything a module developer needs to add visibility management to their services. The patterns are battle-tested and optimize for both user experience and device battery life.
|
This integration guide provides everything a module developer needs to add visibility management to their services. The patterns are battle-tested and optimize for both user experience and device battery life.
|
||||||
|
|
||||||
|
**Key takeaway**: Mobile browsers WILL suspend WebSocket connections when apps lose focus. Integrating with VisibilityService ensures your real-time features work reliably across all platforms and usage patterns.
|
||||||
|
|
@ -423,49 +423,194 @@ export class RelayHub extends BaseService {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chat Service Integration
|
### Chat Service Integration - WebSocket Connection Recovery
|
||||||
|
|
||||||
|
**Real-World Scenario**: User receives a WhatsApp notification, switches to WhatsApp for 2 minutes, then returns to the Nostr chat app. The WebSocket connection was suspended by the mobile browser.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export class ChatService extends BaseService {
|
export class ChatService extends BaseService {
|
||||||
private messageQueue: Message[] = []
|
protected readonly metadata = {
|
||||||
private connectionRetryCount = 0
|
name: 'ChatService',
|
||||||
|
version: '1.0.0',
|
||||||
|
dependencies: ['RelayHub', 'AuthService', 'VisibilityService']
|
||||||
|
}
|
||||||
|
|
||||||
private async handleResume(): Promise<void> {
|
private subscriptionUnsubscriber?: () => void
|
||||||
// Reset retry count on successful resume
|
private visibilityUnsubscribe?: () => void
|
||||||
this.connectionRetryCount = 0
|
|
||||||
|
|
||||||
// Check if we missed any messages while away
|
protected async onInitialize(): Promise<void> {
|
||||||
|
// Set up chat subscription and register with visibility service
|
||||||
|
await this.initializeMessageHandling()
|
||||||
|
this.registerWithVisibilityService()
|
||||||
|
|
||||||
|
this.debug('Chat service fully initialized!')
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerWithVisibilityService(): void {
|
||||||
|
if (!this.visibilityService) return
|
||||||
|
|
||||||
|
this.visibilityUnsubscribe = this.visibilityService.registerService(
|
||||||
|
this.metadata.name,
|
||||||
|
async () => this.handleAppResume(),
|
||||||
|
async () => this.handleAppPause()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STEP 1: App becomes visible again
|
||||||
|
* VisibilityService detects visibility change and calls this method
|
||||||
|
*/
|
||||||
|
private async handleAppResume(): Promise<void> {
|
||||||
|
this.debug('App resumed - checking chat connections')
|
||||||
|
|
||||||
|
// Check if our chat subscription is still active
|
||||||
|
if (!this.subscriptionUnsubscriber) {
|
||||||
|
this.debug('Chat subscription lost, re-establishing...')
|
||||||
|
this.setupMessageSubscription() // Recreate subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync any messages missed while app was hidden
|
||||||
await this.syncMissedMessages()
|
await this.syncMissedMessages()
|
||||||
|
|
||||||
// Process any queued messages
|
|
||||||
await this.processMessageQueue()
|
|
||||||
|
|
||||||
// Resume real-time message monitoring
|
|
||||||
this.startMessageMonitoring()
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handlePause(): Promise<void> {
|
|
||||||
// Queue outgoing messages instead of sending immediately
|
|
||||||
this.enableMessageQueueing()
|
|
||||||
|
|
||||||
// Stop real-time monitoring
|
|
||||||
this.stopMessageMonitoring()
|
|
||||||
|
|
||||||
// Save current conversation state
|
|
||||||
await this.saveConversationState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STEP 2: Sync missed messages from the time we were away
|
||||||
|
*/
|
||||||
private async syncMissedMessages(): Promise<void> {
|
private async syncMissedMessages(): Promise<void> {
|
||||||
const lastSeenTimestamp = this.getLastSeenTimestamp()
|
try {
|
||||||
const missedMessages = await this.fetchMessagesSince(lastSeenTimestamp)
|
const peers = Array.from(this.peers.value.values())
|
||||||
|
const syncPromises = peers.map(peer => this.loadRecentMessagesForPeer(peer.pubkey))
|
||||||
|
|
||||||
for (const message of missedMessages) {
|
await Promise.allSettled(syncPromises)
|
||||||
this.processMessage(message)
|
this.debug('Missed messages sync completed')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to sync missed messages:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STEP 3: Load recent messages for each chat peer
|
||||||
|
*/
|
||||||
|
private async loadRecentMessagesForPeer(peerPubkey: string): Promise<void> {
|
||||||
|
const userPubkey = this.authService?.user?.value?.pubkey
|
||||||
|
if (!userPubkey || !this.relayHub) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get messages from the last hour (while we were away)
|
||||||
|
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 from us to them
|
||||||
|
authors: [userPubkey],
|
||||||
|
'#p': [peerPubkey],
|
||||||
|
since: oneHourAgo,
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// Process any new messages we missed
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STEP 4: Process messages (decrypt, filter market messages, add to chat)
|
||||||
|
*/
|
||||||
|
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) return
|
||||||
|
|
||||||
|
const senderPubkey = event.pubkey
|
||||||
|
const decryptedContent = await nip04.decrypt(userPrivkey, senderPubkey, event.content)
|
||||||
|
|
||||||
|
// Check if this is a market order (type 1 or 2)
|
||||||
|
let isMarketMessage = false
|
||||||
|
try {
|
||||||
|
const parsedContent = JSON.parse(decryptedContent)
|
||||||
|
if (parsedContent.type === 1 || parsedContent.type === 2) {
|
||||||
|
isMarketMessage = true
|
||||||
|
if (this.marketMessageHandler) {
|
||||||
|
await this.marketMessageHandler(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not JSON, treat as regular chat message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to chat if it's not a market message
|
||||||
|
if (!isMarketMessage) {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: event.id,
|
||||||
|
content: decryptedContent,
|
||||||
|
created_at: event.created_at,
|
||||||
|
sent: false,
|
||||||
|
pubkey: senderPubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addPeer(senderPubkey) // Ensure peer exists
|
||||||
|
this.addMessage(senderPubkey, message) // Add to chat history
|
||||||
|
|
||||||
|
console.log('💬 Recovered missed message from:', senderPubkey.slice(0, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process recovered message:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Battery-conscious pause behavior
|
||||||
|
*/
|
||||||
|
private async handleAppPause(): Promise<void> {
|
||||||
|
this.debug('App paused - chat subscription maintained for quick resume')
|
||||||
|
|
||||||
|
// Don't immediately unsubscribe - RelayHub will handle connection management
|
||||||
|
// This allows for quick resume without full subscription recreation overhead
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup when service is disposed
|
||||||
|
*/
|
||||||
|
protected async onDispose(): Promise<void> {
|
||||||
|
if (this.visibilityUnsubscribe) {
|
||||||
|
this.visibilityUnsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.subscriptionUnsubscriber) {
|
||||||
|
this.subscriptionUnsubscriber()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**What happens in this example:**
|
||||||
|
|
||||||
|
1. **🔍 Detection**: VisibilityService detects app became visible after 2+ minutes
|
||||||
|
2. **🔌 Connection Check**: ChatService checks if its subscription is still active
|
||||||
|
3. **📥 Message Recovery**: Queries for missed messages from all chat peers in the last hour
|
||||||
|
4. **🔓 Decryption**: Decrypts and processes each missed message
|
||||||
|
5. **📱 UI Update**: New messages appear in chat history as if they were never missed
|
||||||
|
6. **⚡ Real-time Resume**: Chat subscription is fully restored for new incoming messages
|
||||||
|
|
||||||
|
**The user experience**: Seamless. Messages that arrived while the app was backgrounded appear instantly when the app regains focus.
|
||||||
|
|
||||||
### Custom Service Example
|
### Custom Service Example
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -569,6 +714,74 @@ private async handleAppResume(): Promise<void> {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### WebSocket Connection Issues
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Problem: WebSocket connections not recovering after app backgrounding
|
||||||
|
// Common on mobile browsers (iOS Safari, Chrome mobile)
|
||||||
|
|
||||||
|
// ❌ Incorrect - not integrating with VisibilityService
|
||||||
|
export class MyRealtimeService extends BaseService {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
|
||||||
|
protected async onInitialize(): Promise<void> {
|
||||||
|
this.ws = new WebSocket('wss://example.com')
|
||||||
|
// Missing: visibility service registration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Correct - proper WebSocket recovery integration
|
||||||
|
export class MyRealtimeService extends BaseService {
|
||||||
|
protected readonly metadata = {
|
||||||
|
name: 'MyRealtimeService',
|
||||||
|
dependencies: ['VisibilityService']
|
||||||
|
}
|
||||||
|
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private visibilityUnsubscribe?: () => void
|
||||||
|
|
||||||
|
protected async onInitialize(): Promise<void> {
|
||||||
|
await this.connect()
|
||||||
|
this.registerWithVisibilityService() // ✅ Essential for mobile
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerWithVisibilityService(): void {
|
||||||
|
if (!this.visibilityService) return
|
||||||
|
|
||||||
|
this.visibilityUnsubscribe = this.visibilityService.registerService(
|
||||||
|
this.metadata.name,
|
||||||
|
async () => this.handleAppResume(),
|
||||||
|
async () => this.handleAppPause()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAppResume(): Promise<void> {
|
||||||
|
// Check WebSocket connection health
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
this.debug('WebSocket disconnected, reconnecting...')
|
||||||
|
await this.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore any lost subscriptions
|
||||||
|
await this.restoreSubscriptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAppPause(): Promise<void> {
|
||||||
|
// Don't immediately close WebSocket
|
||||||
|
// Mobile browsers may suspend it anyway
|
||||||
|
this.debug('App paused - WebSocket will be checked on resume')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mobile WebSocket Behavior:**
|
||||||
|
- **iOS Safari**: Suspends WebSocket connections after ~30 seconds in background
|
||||||
|
- **Chrome Mobile**: May suspend connections when memory is needed
|
||||||
|
- **Desktop**: Generally maintains connections but may timeout after extended periods
|
||||||
|
- **PWA Standalone**: Better connection persistence but still subject to system limitations
|
||||||
|
|
||||||
|
**Solution**: Always integrate WebSocket services with VisibilityService for automatic recovery.
|
||||||
|
|
||||||
#### Memory Leaks
|
#### Memory Leaks
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ export const baseModule: ModulePlugin = {
|
||||||
waitForDependencies: false, // VisibilityService has no dependencies
|
waitForDependencies: false, // VisibilityService has no dependencies
|
||||||
maxRetries: 1
|
maxRetries: 1
|
||||||
})
|
})
|
||||||
|
await nostrclientHub.initialize({
|
||||||
|
waitForDependencies: true, // NostrClientHub depends on VisibilityService
|
||||||
|
maxRetries: 3
|
||||||
|
})
|
||||||
|
|
||||||
console.log('✅ Base module installed successfully')
|
console.log('✅ Base module installed successfully')
|
||||||
},
|
},
|
||||||
|
|
@ -63,9 +67,12 @@ export const baseModule: ModulePlugin = {
|
||||||
async uninstall() {
|
async uninstall() {
|
||||||
console.log('🗑️ Uninstalling base module...')
|
console.log('🗑️ Uninstalling base module...')
|
||||||
|
|
||||||
// Cleanup Nostr connections
|
// Cleanup services
|
||||||
relayHub.disconnect()
|
await relayHub.dispose()
|
||||||
nostrclientHub.disconnect?.()
|
await nostrclientHub.dispose()
|
||||||
|
await auth.dispose()
|
||||||
|
await paymentService.dispose()
|
||||||
|
await visibilityService.dispose()
|
||||||
|
|
||||||
console.log('✅ Base module uninstalled')
|
console.log('✅ Base module uninstalled')
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,6 @@
|
||||||
import type { Filter, Event } from 'nostr-tools'
|
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 {
|
export interface NostrclientConfig {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -46,7 +22,18 @@ export interface RelayStatus {
|
||||||
error?: string
|
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 ws: WebSocket | null = null
|
||||||
private config: NostrclientConfig
|
private config: NostrclientConfig
|
||||||
private subscriptions: Map<string, SubscriptionConfig> = new Map()
|
private subscriptions: Map<string, SubscriptionConfig> = new Map()
|
||||||
|
|
@ -54,6 +41,7 @@ export class NostrclientHub extends EventEmitter {
|
||||||
private reconnectAttempts = 0
|
private reconnectAttempts = 0
|
||||||
private readonly maxReconnectAttempts = 5
|
private readonly maxReconnectAttempts = 5
|
||||||
private readonly reconnectDelay = 5000
|
private readonly reconnectDelay = 5000
|
||||||
|
private visibilityUnsubscribe?: () => void
|
||||||
|
|
||||||
// Connection state
|
// Connection state
|
||||||
private _isConnected = false
|
private _isConnected = false
|
||||||
|
|
@ -64,6 +52,42 @@ export class NostrclientHub extends EventEmitter {
|
||||||
this.config = config
|
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 {
|
get isConnected(): boolean {
|
||||||
return this._isConnected
|
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
|
* Connect to the nostrclient WebSocket
|
||||||
|
|
@ -351,6 +368,69 @@ export class NostrclientHub extends EventEmitter {
|
||||||
await this.connect()
|
await this.connect()
|
||||||
}, delay) as unknown as number
|
}, 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
|
// Export singleton instance
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export class ChatService extends BaseService {
|
||||||
protected readonly metadata = {
|
protected readonly metadata = {
|
||||||
name: 'ChatService',
|
name: 'ChatService',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
dependencies: ['RelayHub', 'AuthService']
|
dependencies: ['RelayHub', 'AuthService', 'VisibilityService']
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service-specific state
|
// Service-specific state
|
||||||
|
|
@ -23,6 +23,7 @@ export class ChatService extends BaseService {
|
||||||
private config: ChatConfig
|
private config: ChatConfig
|
||||||
private subscriptionUnsubscriber?: () => void
|
private subscriptionUnsubscriber?: () => void
|
||||||
private marketMessageHandler?: (event: any) => Promise<void>
|
private marketMessageHandler?: (event: any) => Promise<void>
|
||||||
|
private visibilityUnsubscribe?: () => void
|
||||||
|
|
||||||
constructor(config: ChatConfig) {
|
constructor(config: ChatConfig) {
|
||||||
super()
|
super()
|
||||||
|
|
@ -71,6 +72,9 @@ export class ChatService extends BaseService {
|
||||||
// Initialize message handling (subscription + history loading)
|
// Initialize message handling (subscription + history loading)
|
||||||
await this.initializeMessageHandling()
|
await this.initializeMessageHandling()
|
||||||
|
|
||||||
|
// Register with visibility service
|
||||||
|
this.registerWithVisibilityService()
|
||||||
|
|
||||||
this.debug('Chat service fully initialized and ready!')
|
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 userPubkey = this.authService.user.value.pubkey
|
||||||
const userPrivkey = this.authService.user.value.prvkey
|
|
||||||
|
|
||||||
// Subscribe to encrypted direct messages (kind 4) addressed to this user
|
// Subscribe to encrypted direct messages (kind 4) addressed to this user
|
||||||
this.subscriptionUnsubscriber = this.relayHub.subscribe({
|
this.subscriptionUnsubscriber = this.relayHub.subscribe({
|
||||||
|
|
@ -503,61 +506,12 @@ export class ChatService extends BaseService {
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
onEvent: async (event: Event) => {
|
onEvent: async (event: Event) => {
|
||||||
try {
|
// Skip our own messages
|
||||||
// Find the sender's pubkey from the event
|
if (event.pubkey === userPubkey) {
|
||||||
const senderPubkey = event.pubkey
|
return
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.processIncomingMessage(event)
|
||||||
},
|
},
|
||||||
onEose: () => {
|
onEose: () => {
|
||||||
console.log('Chat message subscription EOSE received')
|
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)
|
* Cleanup when service is disposed (overrides BaseService)
|
||||||
*/
|
*/
|
||||||
protected async onDispose(): Promise<void> {
|
protected async onDispose(): Promise<void> {
|
||||||
|
// Unregister from visibility service
|
||||||
|
if (this.visibilityUnsubscribe) {
|
||||||
|
this.visibilityUnsubscribe()
|
||||||
|
this.visibilityUnsubscribe = undefined
|
||||||
|
}
|
||||||
|
|
||||||
// Unsubscribe from message subscription
|
// Unsubscribe from message subscription
|
||||||
if (this.subscriptionUnsubscriber) {
|
if (this.subscriptionUnsubscriber) {
|
||||||
this.subscriptionUnsubscriber()
|
this.subscriptionUnsubscriber()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue