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
|
|
@ -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
|
||||
export class ChatService extends BaseService {
|
||||
private messageQueue: Message[] = []
|
||||
private connectionRetryCount = 0
|
||||
protected readonly metadata = {
|
||||
name: 'ChatService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['RelayHub', 'AuthService', 'VisibilityService']
|
||||
}
|
||||
|
||||
private subscriptionUnsubscriber?: () => void
|
||||
private visibilityUnsubscribe?: () => void
|
||||
|
||||
private async handleResume(): Promise<void> {
|
||||
// Reset retry count on successful resume
|
||||
this.connectionRetryCount = 0
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// Set up chat subscription and register with visibility service
|
||||
await this.initializeMessageHandling()
|
||||
this.registerWithVisibilityService()
|
||||
|
||||
// Check if we missed any messages while away
|
||||
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()
|
||||
|
||||
// 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> {
|
||||
const lastSeenTimestamp = this.getLastSeenTimestamp()
|
||||
const missedMessages = await this.fetchMessagesSince(lastSeenTimestamp)
|
||||
try {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
|
||||
for (const message of missedMessages) {
|
||||
this.processMessage(message)
|
||||
// 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
|
||||
|
||||
```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
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue