- 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>
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
- Architecture
- API Reference
- Integration Guide
- Best Practices
- Mobile Optimization
- Troubleshooting
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
- App becomes hidden → Stop health checks → Schedule pause (5s delay)
- App becomes visible → Calculate hidden duration → Resume services if needed
- Network offline → Immediately pause all services
- 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:
- 🔍 Detection: VisibilityService detects app became visible after 2+ minutes
- 🔌 Connection Check: ChatService checks if its subscription is still active
- 📥 Message Recovery: Queries for missed messages from all chat peers in the last hour
- 🔓 Decryption: Decrypts and processes each missed message
- 📱 UI Update: New messages appear in chat history as if they were never missed
- ⚡ 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.