web-app/docs/03-core-services/visibility-service-integration.md
padreug cdf099e45f Create comprehensive Obsidian-style documentation structure
- Reorganize all markdown documentation into structured docs/ folder
- Create 7 main documentation categories (00-overview through 06-deployment)
- Add comprehensive index files for each category with cross-linking
- Implement Obsidian-compatible [[link]] syntax throughout
- Move legacy/deprecated documentation to archive folder
- Establish documentation standards and maintenance guidelines
- Provide complete coverage of modular architecture, services, and deployment
- Enable better navigation and discoverability for developers and contributors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 14:31:27 +02:00

16 KiB

VisibilityService Integration Guide for Module Developers

Quick Start

1. Basic Service Integration

// 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.)

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

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

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

// 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()

    protected async onInitialize(): Promise<void> {
      await this.setupService()
      this.registerWithVisibilityService() // ✅
    }
    
  • Check hidden duration before expensive operations

    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

    protected async onDispose(): Promise<void> {
      if (this.visibilityUnsubscribe) {
        this.visibilityUnsubscribe() // ✅
      }
    }
    
  • Use graceful pause strategies

    private async handleAppPause(): Promise<void> {
      this.stopHeartbeat() // ✅ Stop periodic tasks
      // Keep connections alive for quick resume
    }
    

Don'ts

  • Don't immediately disconnect on pause

    private async handleAppPause(): Promise<void> {
      this.disconnectAll() // ❌ Too aggressive
    }
    
  • Don't ignore the service availability check

    private registerWithVisibilityService(): void {
      // ❌ Missing availability check
      this.visibilityService.registerService(/*...*/)
    
      // ✅ Correct
      if (!this.visibilityService) return
      this.visibilityService.registerService(/*...*/)
    }
    
  • Don't forget dependencies in metadata

    protected readonly metadata = {
      name: 'MyService',
      dependencies: [] // ❌ Should include 'VisibilityService'
    }
    

Common Patterns

Connection Health Checking

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

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

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

// 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

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.

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.

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.

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

// 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

// 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

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.