- 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.
642 lines
No EOL
16 KiB
Markdown
642 lines
No EOL
16 KiB
Markdown
# VisibilityService Integration Guide for Module Developers
|
|
|
|
## Quick Start
|
|
|
|
### 1. Basic Service Integration
|
|
|
|
```typescript
|
|
// src/modules/your-module/services/your-service.ts
|
|
import { BaseService } from '@/core/base/BaseService'
|
|
|
|
export class YourService extends BaseService {
|
|
protected readonly metadata = {
|
|
name: 'YourService',
|
|
version: '1.0.0',
|
|
dependencies: ['VisibilityService'] // Optional but recommended
|
|
}
|
|
|
|
private visibilityUnsubscribe?: () => void
|
|
|
|
protected async onInitialize(): Promise<void> {
|
|
// Your initialization code
|
|
await this.setupService()
|
|
|
|
// Register with visibility service
|
|
this.registerWithVisibilityService()
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
private async handleAppResume(): Promise<void> {
|
|
this.debug('App resumed - checking connections')
|
|
|
|
// 1. Check if reconnection is needed
|
|
if (await this.needsReconnection()) {
|
|
await this.reconnectService()
|
|
}
|
|
|
|
// 2. Resume normal operations
|
|
this.resumeBackgroundTasks()
|
|
}
|
|
|
|
private async handleAppPause(): Promise<void> {
|
|
this.debug('App paused - reducing activity')
|
|
|
|
// 1. Stop non-essential tasks
|
|
this.pauseBackgroundTasks()
|
|
|
|
// 2. Prepare for potential disconnection
|
|
await this.prepareForPause()
|
|
}
|
|
|
|
protected async onDispose(): Promise<void> {
|
|
// Always clean up registration
|
|
if (this.visibilityUnsubscribe) {
|
|
this.visibilityUnsubscribe()
|
|
}
|
|
|
|
this.debug('Service disposed')
|
|
}
|
|
|
|
// Implement these methods based on your service needs
|
|
private async needsReconnection(): Promise<boolean> {
|
|
// Check if your service connections are healthy
|
|
return false
|
|
}
|
|
|
|
private async reconnectService(): Promise<void> {
|
|
// Reconnect your service
|
|
}
|
|
|
|
private resumeBackgroundTasks(): void {
|
|
// Resume periodic tasks, polling, etc.
|
|
}
|
|
|
|
private pauseBackgroundTasks(): void {
|
|
// Pause periodic tasks to save battery
|
|
}
|
|
|
|
private async prepareForPause(): Promise<void> {
|
|
// Save state, queue operations, etc.
|
|
}
|
|
}
|
|
```
|
|
|
|
## Integration Patterns by Service Type
|
|
|
|
### Real-Time Connection Services (WebSocket, Nostr, etc.)
|
|
|
|
```typescript
|
|
export class RealtimeService extends BaseService {
|
|
private connections = new Map<string, Connection>()
|
|
private subscriptions = new Map<string, Subscription>()
|
|
|
|
private async handleAppResume(): Promise<void> {
|
|
// 1. Check connection health
|
|
const brokenConnections = await this.checkConnectionHealth()
|
|
|
|
// 2. Reconnect failed connections
|
|
for (const connectionId of brokenConnections) {
|
|
await this.reconnectConnection(connectionId)
|
|
}
|
|
|
|
// 3. Restore subscriptions
|
|
await this.restoreSubscriptions()
|
|
|
|
// 4. Resume heartbeat/keepalive
|
|
this.startHeartbeat()
|
|
}
|
|
|
|
private async handleAppPause(): Promise<void> {
|
|
// 1. Stop heartbeat to save battery
|
|
this.stopHeartbeat()
|
|
|
|
// 2. Don't disconnect immediately (for quick resume)
|
|
// Connections will be checked when app resumes
|
|
}
|
|
|
|
private async checkConnectionHealth(): Promise<string[]> {
|
|
const broken: string[] = []
|
|
|
|
for (const [id, connection] of this.connections) {
|
|
if (!connection.isConnected()) {
|
|
broken.push(id)
|
|
}
|
|
}
|
|
|
|
return broken
|
|
}
|
|
}
|
|
```
|
|
|
|
### Data Sync Services
|
|
|
|
```typescript
|
|
export class DataSyncService extends BaseService {
|
|
private syncQueue: Operation[] = []
|
|
private lastSyncTime: number = 0
|
|
|
|
private async handleAppResume(): Promise<void> {
|
|
const hiddenTime = Date.now() - this.lastSyncTime
|
|
|
|
// If hidden for > 5 minutes, do full sync
|
|
if (hiddenTime > 300000) {
|
|
await this.performFullSync()
|
|
} else {
|
|
await this.performIncrementalSync()
|
|
}
|
|
|
|
// Process queued operations
|
|
await this.processQueue()
|
|
|
|
// Resume periodic sync
|
|
this.startPeriodicSync()
|
|
}
|
|
|
|
private async handleAppPause(): Promise<void> {
|
|
// Stop periodic sync
|
|
this.stopPeriodicSync()
|
|
|
|
// Save timestamp
|
|
this.lastSyncTime = Date.now()
|
|
|
|
// Enable operation queueing
|
|
this.enableQueueMode()
|
|
}
|
|
}
|
|
```
|
|
|
|
### Background Processing Services
|
|
|
|
```typescript
|
|
export class BackgroundService extends BaseService {
|
|
private processingInterval?: number
|
|
private taskQueue: Task[] = []
|
|
|
|
private async handleAppResume(): Promise<void> {
|
|
// Resume background processing
|
|
this.startProcessing()
|
|
|
|
// Process any queued tasks
|
|
await this.processQueuedTasks()
|
|
}
|
|
|
|
private async handleAppPause(): Promise<void> {
|
|
// Stop background processing to save CPU/battery
|
|
if (this.processingInterval) {
|
|
clearInterval(this.processingInterval)
|
|
this.processingInterval = undefined
|
|
}
|
|
|
|
// Queue new tasks instead of processing immediately
|
|
this.enableTaskQueueing()
|
|
}
|
|
}
|
|
```
|
|
|
|
## Module Registration Pattern
|
|
|
|
### Module Index File
|
|
|
|
```typescript
|
|
// src/modules/your-module/index.ts
|
|
import type { App } from 'vue'
|
|
import type { ModulePlugin } from '@/core/types'
|
|
import { YourService } from './services/your-service'
|
|
|
|
export const yourModule: ModulePlugin = {
|
|
name: 'your-module',
|
|
version: '1.0.0',
|
|
dependencies: ['base'], // base module provides VisibilityService
|
|
|
|
async install(app: App, options?: any) {
|
|
console.log('🔧 Installing your module...')
|
|
|
|
// Create and initialize service
|
|
const yourService = new YourService()
|
|
|
|
// Initialize service (this will register with VisibilityService)
|
|
await yourService.initialize({
|
|
waitForDependencies: true, // Wait for VisibilityService
|
|
maxRetries: 3
|
|
})
|
|
|
|
// Register service in DI container
|
|
container.provide(YOUR_SERVICE_TOKEN, yourService)
|
|
|
|
console.log('✅ Your module installed successfully')
|
|
},
|
|
|
|
async uninstall() {
|
|
console.log('🗑️ Uninstalling your module...')
|
|
// Services will auto-dispose and unregister from VisibilityService
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices Checklist
|
|
|
|
### ✅ Do's
|
|
|
|
- **Always register during `onInitialize()`**
|
|
```typescript
|
|
protected async onInitialize(): Promise<void> {
|
|
await this.setupService()
|
|
this.registerWithVisibilityService() // ✅
|
|
}
|
|
```
|
|
|
|
- **Check hidden duration before expensive operations**
|
|
```typescript
|
|
private async handleAppResume(): Promise<void> {
|
|
const state = this.visibilityService.getState()
|
|
if (state.hiddenDuration && state.hiddenDuration > 30000) {
|
|
await this.performFullReconnect() // ✅ Only if needed
|
|
}
|
|
}
|
|
```
|
|
|
|
- **Always clean up registrations**
|
|
```typescript
|
|
protected async onDispose(): Promise<void> {
|
|
if (this.visibilityUnsubscribe) {
|
|
this.visibilityUnsubscribe() // ✅
|
|
}
|
|
}
|
|
```
|
|
|
|
- **Use graceful pause strategies**
|
|
```typescript
|
|
private async handleAppPause(): Promise<void> {
|
|
this.stopHeartbeat() // ✅ Stop periodic tasks
|
|
// Keep connections alive for quick resume
|
|
}
|
|
```
|
|
|
|
### ❌ Don'ts
|
|
|
|
- **Don't immediately disconnect on pause**
|
|
```typescript
|
|
private async handleAppPause(): Promise<void> {
|
|
this.disconnectAll() // ❌ Too aggressive
|
|
}
|
|
```
|
|
|
|
- **Don't ignore the service availability check**
|
|
```typescript
|
|
private registerWithVisibilityService(): void {
|
|
// ❌ Missing availability check
|
|
this.visibilityService.registerService(/*...*/)
|
|
|
|
// ✅ Correct
|
|
if (!this.visibilityService) return
|
|
this.visibilityService.registerService(/*...*/)
|
|
}
|
|
```
|
|
|
|
- **Don't forget dependencies in metadata**
|
|
```typescript
|
|
protected readonly metadata = {
|
|
name: 'MyService',
|
|
dependencies: [] // ❌ Should include 'VisibilityService'
|
|
}
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Connection Health Checking
|
|
|
|
```typescript
|
|
private async checkConnectionHealth(): Promise<boolean> {
|
|
try {
|
|
// Perform a lightweight health check
|
|
await this.ping()
|
|
return true
|
|
} catch (error) {
|
|
this.debug('Connection health check failed:', error)
|
|
return false
|
|
}
|
|
}
|
|
```
|
|
|
|
### Subscription Restoration
|
|
|
|
```typescript
|
|
private async restoreSubscriptions(): Promise<void> {
|
|
const subscriptionsToRestore = Array.from(this.subscriptionConfigs.values())
|
|
|
|
for (const config of subscriptionsToRestore) {
|
|
try {
|
|
await this.recreateSubscription(config)
|
|
} catch (error) {
|
|
this.debug(`Failed to restore subscription ${config.id}:`, error)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Operation Queueing
|
|
|
|
```typescript
|
|
private operationQueue: Operation[] = []
|
|
private queueingEnabled = false
|
|
|
|
private async executeOrQueue(operation: Operation): Promise<void> {
|
|
if (this.queueingEnabled) {
|
|
this.operationQueue.push(operation)
|
|
} else {
|
|
await operation.execute()
|
|
}
|
|
}
|
|
|
|
private async processQueue(): Promise<void> {
|
|
const operations = this.operationQueue.splice(0) // Clear queue
|
|
|
|
for (const operation of operations) {
|
|
try {
|
|
await operation.execute()
|
|
} catch (error) {
|
|
this.debug('Queued operation failed:', error)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Integration
|
|
|
|
### Mock VisibilityService for Tests
|
|
|
|
```typescript
|
|
// tests/setup/mockVisibilityService.ts
|
|
export const createMockVisibilityService = () => ({
|
|
isVisible: { value: true },
|
|
isOnline: { value: true },
|
|
registerService: vi.fn(() => vi.fn()), // Returns unregister function
|
|
getState: vi.fn(() => ({
|
|
isVisible: true,
|
|
isOnline: true,
|
|
hiddenDuration: 0
|
|
}))
|
|
})
|
|
|
|
// In your test
|
|
describe('YourService', () => {
|
|
it('should register with VisibilityService', async () => {
|
|
const mockVisibility = createMockVisibilityService()
|
|
const service = new YourService()
|
|
service.visibilityService = mockVisibility
|
|
|
|
await service.initialize()
|
|
|
|
expect(mockVisibility.registerService).toHaveBeenCalledWith(
|
|
'YourService',
|
|
expect.any(Function),
|
|
expect.any(Function)
|
|
)
|
|
})
|
|
})
|
|
```
|
|
|
|
### Test Visibility Events
|
|
|
|
```typescript
|
|
it('should handle app resume correctly', async () => {
|
|
const service = new YourService()
|
|
const reconnectSpy = vi.spyOn(service, 'reconnect')
|
|
|
|
// Simulate app resume after long pause
|
|
await service.handleAppResume()
|
|
|
|
expect(reconnectSpy).toHaveBeenCalled()
|
|
})
|
|
```
|
|
|
|
## 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.
|
|
|
|
**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. |