web-app/docs/03-core-services/visibility-service.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

25 KiB

VisibilityService Documentation

Overview

The VisibilityService is a centralized service that monitors app visibility state and coordinates connection recovery across all modules. It's designed to optimize battery life on mobile devices while ensuring reliable reconnections when the app becomes visible again.

Table of Contents


Core Concepts

Visibility States

The service tracks multiple visibility-related states:

interface VisibilityState {
  isVisible: boolean      // Document is visible and focused
  isOnline: boolean       // Network connectivity status
  lastHiddenAt: number    // Timestamp when app was hidden
  lastVisibleAt: number   // Timestamp when app became visible
  hiddenDuration: number  // How long the app was hidden (ms)
}

Reconnection Strategy

The service uses intelligent thresholds to determine when reconnection is needed:

  • Reconnection Threshold: 30 seconds (configurable)
  • Debounce Delay: 100ms for rapid visibility changes
  • Health Check Interval: 5 seconds when visible
  • Pause Delay: 5 seconds before pausing services

Service Registration

Services register callbacks for pause/resume operations:

const unregister = visibilityService.registerService(
  'ServiceName',
  async () => handleResume(),  // Called when app becomes visible
  async () => handlePause()    // Called when app becomes hidden
)

Architecture

Core Components

graph TB
    VS[VisibilityService] --> |monitors| DOM[Document Events]
    VS --> |monitors| WIN[Window Events]  
    VS --> |monitors| NET[Network Events]
    
    VS --> |manages| RH[RelayHub]
    VS --> |manages| CS[ChatService]
    VS --> |manages| OTHER[Other Services]
    
    RH --> |reconnects| RELAYS[Nostr Relays]
    RH --> |restores| SUBS[Subscriptions]

Event Flow

  1. App becomes hidden → Stop health checks → Schedule pause (5s delay)
  2. App becomes visible → Calculate hidden duration → Resume services if needed
  3. Network offline → Immediately pause all services
  4. Network online → Resume all services if app is visible

API Reference

VisibilityService Class

Properties

// Reactive state (read-only)
readonly isVisible: ComputedRef<boolean>
readonly isOnline: ComputedRef<boolean>
readonly isPaused: ComputedRef<boolean>
readonly lastHiddenAt: ComputedRef<number | null>
readonly lastVisibleAt: ComputedRef<number | null>
readonly hiddenDuration: ComputedRef<number | null>
readonly needsReconnection: ComputedRef<boolean>

Methods

// Service registration
registerService(
  name: string,
  onResume: () => Promise<void>,
  onPause: () => Promise<void>
): () => void

// Manual control
forceConnectionCheck(): Promise<void>
getState(): VisibilityState

BaseService Integration

All services extending BaseService automatically have access to visibilityService:

export class MyService extends BaseService {
  protected readonly metadata = {
    name: 'MyService',
    version: '1.0.0',
    dependencies: ['VisibilityService'] // Optional: declare dependency
  }

  protected async onInitialize(): Promise<void> {
    // Register for visibility management
    this.registerWithVisibilityService()
  }

  private registerWithVisibilityService(): void {
    if (!this.visibilityService) return

    this.visibilityService.registerService(
      'MyService',
      async () => this.handleResume(),
      async () => this.handlePause()
    )
  }
}

Integration Guide

Step 1: Service Registration

Register your service during initialization:

protected async onInitialize(): Promise<void> {
  // Your service initialization code
  await this.setupConnections()
  
  // Register with visibility service
  this.visibilityUnsubscribe = this.visibilityService?.registerService(
    this.metadata.name,
    async () => this.handleAppResume(),
    async () => this.handleAppPause()
  )
}

Step 2: Implement Resume Handler

Handle app resuming (visibility restored):

private async handleAppResume(): Promise<void> {
  this.debug('App resumed, checking connections')
  
  // 1. Check connection health
  const needsReconnection = await this.checkConnectionHealth()
  
  // 2. Reconnect if necessary
  if (needsReconnection) {
    await this.reconnect()
  }
  
  // 3. Restore any lost subscriptions
  await this.restoreSubscriptions()
  
  // 4. Resume normal operations
  this.startBackgroundTasks()
}

Step 3: Implement Pause Handler

Handle app pausing (visibility lost):

private async handleAppPause(): Promise<void> {
  this.debug('App paused, reducing activity')
  
  // 1. Stop non-essential background tasks
  this.stopBackgroundTasks()
  
  // 2. Reduce connection activity (don't disconnect immediately)
  this.reduceConnectionActivity()
  
  // 3. Save any pending state
  await this.saveCurrentState()
}

Step 4: Cleanup

Unregister when service is disposed:

protected async onDispose(): Promise<void> {
  // Unregister from visibility service
  if (this.visibilityUnsubscribe) {
    this.visibilityUnsubscribe()
    this.visibilityUnsubscribe = undefined
  }
  
  // Other cleanup...
}

Best Practices

Do's

// ✅ Register during service initialization
protected async onInitialize(): Promise<void> {
  this.registerWithVisibilityService()
}

// ✅ Check connection health before resuming
private async handleAppResume(): Promise<void> {
  if (await this.needsReconnection()) {
    await this.reconnect()
  }
}

// ✅ Graceful pause - don't immediately disconnect
private async handleAppPause(): Promise<void> {
  this.stopHealthChecks() // Stop periodic tasks
  // Keep connections alive for quick resume
}

// ✅ Handle network events separately
private async handleNetworkChange(isOnline: boolean): Promise<void> {
  if (isOnline) {
    await this.forceReconnection()
  } else {
    this.pauseNetworkOperations()
  }
}

// ✅ Store subscription configurations for restoration
private subscriptionConfigs = new Map<string, SubscriptionConfig>()

Don'ts

// ❌ Don't immediately disconnect on pause
private async handleAppPause(): Promise<void> {
  this.disconnect() // Too aggressive for quick tab switches
}

// ❌ Don't ignore hidden duration
private async handleAppResume(): Promise<void> {
  await this.reconnect() // Should check if reconnection is needed
}

// ❌ Don't handle visibility changes without debouncing
document.addEventListener('visibilitychange', () => {
  this.handleVisibilityChange() // Can fire rapidly
})

// ❌ Don't forget to clean up registrations
protected async onDispose(): Promise<void> {
  // Missing: this.visibilityUnsubscribe?.()
}

Performance Optimizations

class OptimizedService extends BaseService {
  private connectionHealthCache = new Map<string, { 
    isHealthy: boolean, 
    lastChecked: number 
  }>()

  private async checkConnectionHealth(): Promise<boolean> {
    const now = Date.now()
    const cached = this.connectionHealthCache.get('main')
    
    // Use cached result if recent (within 5 seconds)
    if (cached && (now - cached.lastChecked) < 5000) {
      return cached.isHealthy
    }
    
    // Perform actual health check
    const isHealthy = await this.performHealthCheck()
    this.connectionHealthCache.set('main', { isHealthy, lastChecked: now })
    
    return isHealthy
  }
}

Mobile Optimization

Battery Life Considerations

The service optimizes for mobile battery life:

// Configurable thresholds for different platforms
const MOBILE_CONFIG = {
  reconnectThreshold: 30000,    // 30s before reconnection needed
  debounceDelay: 100,           // 100ms debounce for rapid changes
  healthCheckInterval: 5000,    // 5s health checks when visible
  pauseDelay: 5000             // 5s delay before pausing
}

const DESKTOP_CONFIG = {
  reconnectThreshold: 60000,    // 60s (desktop tabs stay connected longer)
  debounceDelay: 50,           // 50ms (faster response)
  healthCheckInterval: 3000,    // 3s (more frequent checks)
  pauseDelay: 10000            // 10s (longer delay before pausing)
}

Browser-Specific Handling

// iOS Safari specific events
window.addEventListener('pageshow', () => this.handleAppVisible())
window.addEventListener('pagehide', () => this.handleAppHidden())

// Standard visibility API (all modern browsers)  
document.addEventListener('visibilitychange', this.visibilityHandler)

// Desktop focus handling
window.addEventListener('focus', this.focusHandler)
window.addEventListener('blur', this.blurHandler)

// Network status
window.addEventListener('online', this.onlineHandler)
window.addEventListener('offline', this.offlineHandler)

PWA/Standalone App Handling

// Detect if running as standalone PWA
const isStandalone = window.matchMedia('(display-mode: standalone)').matches

// Adjust behavior for standalone apps
const config = isStandalone ? {
  ...MOBILE_CONFIG,
  reconnectThreshold: 15000,  // Shorter threshold for PWAs
  healthCheckInterval: 2000   // More frequent checks for better UX
} : MOBILE_CONFIG

Real-World Examples

RelayHub Integration

export class RelayHub extends BaseService {
  private subscriptions = new Map<string, SubscriptionConfig>()
  private activeSubscriptions = new Map<string, any>()

  protected async onInitialize(): Promise<void> {
    // Initialize connections
    await this.connect()
    this.startHealthCheck()
    
    // Register with visibility service
    this.registerWithVisibilityService()
  }

  private async handleResume(): Promise<void> {
    this.debug('Handling resume from visibility change')

    // Check which relays disconnected
    const disconnectedRelays = this.checkDisconnectedRelays()
    
    if (disconnectedRelays.length > 0) {
      this.debug(`Found ${disconnectedRelays.length} disconnected relays`)
      await this.reconnectToRelays(disconnectedRelays)
    }

    // Restore all subscriptions
    await this.restoreSubscriptions()

    // Resume health check
    this.startHealthCheck()
  }

  private async handlePause(): Promise<void> {
    this.debug('Handling pause from visibility change')

    // Stop health check while paused (saves battery)
    if (this.healthCheckInterval) {
      clearInterval(this.healthCheckInterval)
      this.healthCheckInterval = undefined
    }

    // Don't disconnect immediately - connections will be verified on resume
  }
}

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.

export class ChatService extends BaseService {
  protected readonly metadata = {
    name: 'ChatService',
    version: '1.0.0',
    dependencies: ['RelayHub', 'AuthService', 'VisibilityService']
  }
  
  private subscriptionUnsubscriber?: () => void
  private visibilityUnsubscribe?: () => void

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

  /**
   * STEP 2: Sync missed messages from the time we were away
   */
  private async syncMissedMessages(): Promise<void> {
    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')
    
    // 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

export class DataSyncService extends BaseService {
  private syncQueue: SyncOperation[] = []
  private lastSyncTimestamp: number = 0

  protected readonly metadata = {
    name: 'DataSyncService',
    version: '1.0.0',
    dependencies: ['VisibilityService', 'RelayHub']
  }

  protected async onInitialize(): Promise<void> {
    this.registerWithVisibilityService()
    this.startPeriodicSync()
  }

  private registerWithVisibilityService(): void {
    if (!this.visibilityService) {
      this.debug('VisibilityService not available')
      return
    }

    this.visibilityUnsubscribe = this.visibilityService.registerService(
      'DataSyncService',
      async () => this.handleAppResume(),
      async () => this.handleAppPause()
    )
  }

  private async handleAppResume(): Promise<void> {
    const hiddenDuration = Date.now() - this.lastSyncTimestamp

    // If we were hidden for more than 5 minutes, do a full sync
    if (hiddenDuration > 300000) {
      await this.performFullSync()
    } else {
      // Otherwise just sync changes since we paused
      await this.performIncrementalSync()
    }

    // Process any queued sync operations
    await this.processSyncQueue()
    
    // Resume periodic sync
    this.startPeriodicSync()
  }

  private async handleAppPause(): Promise<void> {
    // Stop periodic sync to save battery
    this.stopPeriodicSync()
    
    // Queue any pending operations instead of executing immediately
    this.enableOperationQueueing()
    
    // Save current sync state
    this.lastSyncTimestamp = Date.now()
    await this.saveSyncState()
  }
}

Troubleshooting

Common Issues

Services Not Resuming

// Problem: Service not registered properly
// Solution: Check registration in onInitialize()

protected async onInitialize(): Promise<void> {
  // ❌ Incorrect - missing registration
  await this.setupService()
  
  // ✅ Correct - register with visibility service
  await this.setupService()
  this.registerWithVisibilityService()
}

Excessive Reconnections

// Problem: Not checking hidden duration
// Solution: Implement proper threshold checking

private async handleAppResume(): Promise<void> {
  // ❌ Incorrect - always reconnects
  await this.reconnect()
  
  // ✅ Correct - check if reconnection is needed
  const state = this.visibilityService.getState()
  if (state.hiddenDuration && state.hiddenDuration > 30000) {
    await this.reconnect()
  }
}

WebSocket Connection Issues

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

// Problem: Not cleaning up registrations
// Solution: Proper disposal in onDispose()

protected async onDispose(): Promise<void> {
  // ✅ Always clean up registrations
  if (this.visibilityUnsubscribe) {
    this.visibilityUnsubscribe()
    this.visibilityUnsubscribe = undefined
  }
  
  // Clean up other resources
  this.clearTimers()
  this.closeConnections()
}

Debugging

Enable debug logging for visibility-related issues:

// In your service constructor or initialization
constructor() {
  super()
  
  // Enable visibility service debugging
  if (this.visibilityService) {
    this.visibilityService.on('debug', (message: string, data?: any) => {
      console.log(`[VisibilityService] ${message}`, data)
    })
  }
}

Check visibility state in browser console:

// Get current visibility state
console.log(visibilityService.getState())

// Force connection check
await visibilityService.forceConnectionCheck()

// Check service registrations
console.log(visibilityService.subscribedServices.size)

Configuration

Environment-Based Configuration

// src/config/visibility.ts
export const getVisibilityConfig = () => {
  const isMobile = /Mobile|Android|iPhone|iPad/.test(navigator.userAgent)
  const isPWA = window.matchMedia('(display-mode: standalone)').matches
  
  if (isPWA) {
    return {
      reconnectThreshold: 15000,  // 15s for PWAs
      debounceDelay: 50,
      healthCheckInterval: 2000,
      pauseDelay: 3000
    }
  } else if (isMobile) {
    return {
      reconnectThreshold: 30000,  // 30s for mobile web
      debounceDelay: 100,
      healthCheckInterval: 5000,
      pauseDelay: 5000
    }
  } else {
    return {
      reconnectThreshold: 60000,  // 60s for desktop
      debounceDelay: 50,
      healthCheckInterval: 3000,
      pauseDelay: 10000
    }
  }
}

Module-Specific Configuration

// Each module can provide custom visibility config
export interface ModuleVisibilityConfig {
  enableVisibilityManagement?: boolean
  customThresholds?: {
    reconnectThreshold?: number
    pauseDelay?: number
  }
  criticalService?: boolean  // Never pause critical services
}

export const chatModule: ModulePlugin = {
  name: 'chat',
  visibilityConfig: {
    enableVisibilityManagement: true,
    customThresholds: {
      reconnectThreshold: 10000,  // Chat needs faster reconnection
      pauseDelay: 2000
    },
    criticalService: false
  }
}

Summary

The VisibilityService provides a powerful, centralized way to manage app visibility and connection states across all modules. By following the integration patterns and best practices outlined in this documentation, your services will automatically benefit from:

  • Optimized battery life on mobile devices
  • Reliable connection recovery after app visibility changes
  • Intelligent reconnection logic based on hidden duration
  • Seamless subscription restoration for real-time features
  • Cross-platform compatibility for web, mobile, and PWA

The modular architecture ensures that adding visibility management to any service is straightforward while maintaining the flexibility to customize behavior per service needs.

For questions or issues, check the troubleshooting section or review the real-world examples for implementation guidance.