Add VisibilityService for managing app visibility state and connection recovery

- Introduce a new VisibilityService to handle application visibility state, including online/offline status and connection management.
- Update DI container to register the new VisibilityService and integrate it into the base module.
- Modify BaseService to include visibilityService as a dependency, ensuring proper initialization and error handling.
- Enhance RelayHub to register with VisibilityService for improved connection management during visibility changes.
- Refactor related components to utilize the new service, streamlining visibility handling across the application.
This commit is contained in:
padreug 2025-09-05 15:34:09 +02:00
parent 099c16abc9
commit 3e9c9bbdef
5 changed files with 588 additions and 60 deletions

View file

@ -45,6 +45,7 @@ export abstract class BaseService {
protected relayHub: any = null
protected authService: any = null
protected nostrClientHub: any = null
protected visibilityService: any = null
// Service state
public readonly isInitialized: Ref<boolean> = ref(false)
@ -134,6 +135,7 @@ export abstract class BaseService {
this.relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB)
this.authService = tryInjectService(SERVICE_TOKENS.AUTH_SERVICE)
this.nostrClientHub = tryInjectService(SERVICE_TOKENS.NOSTR_CLIENT_HUB)
this.visibilityService = tryInjectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
// Check if all required dependencies are available
const missingDeps = this.getMissingDependencies()
@ -181,6 +183,9 @@ export abstract class BaseService {
if (deps.includes('NostrClientHub') && !this.nostrClientHub) {
missing.push('NostrClientHub')
}
if (deps.includes('VisibilityService') && !this.visibilityService) {
missing.push('VisibilityService')
}
return missing
}

View file

@ -117,6 +117,9 @@ export const SERVICE_TOKENS = {
// Payment services
PAYMENT_SERVICE: Symbol('paymentService'),
// Visibility services
VISIBILITY_SERVICE: Symbol('visibilityService'),
// Market services
MARKET_STORE: Symbol('marketStore'),
PAYMENT_MONITOR: Symbol('paymentMonitor'),

View file

@ -0,0 +1,390 @@
import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService'
export interface VisibilityState {
isVisible: boolean
isOnline: boolean
lastHiddenAt: number | null
lastVisibleAt: number | null
hiddenDuration: number | null
}
export interface VisibilityOptions {
reconnectThreshold?: number // Time in ms before reconnection is needed (default: 30s)
debounceDelay?: number // Debounce rapid visibility changes (default: 100ms)
checkInterval?: number // Interval to check connection health (default: 5s when visible)
}
/**
* Centralized Visibility Service
* Manages app visibility state and coordinates connection recovery
*/
export class VisibilityService extends BaseService {
// Service metadata
protected readonly metadata = {
name: 'VisibilityService',
version: '1.0.0',
dependencies: []
}
// Configuration
private readonly reconnectThreshold = 30000 // 30 seconds
private readonly debounceDelay = 100 // 100ms
private readonly checkInterval = 5000 // 5 seconds
// State
private _isVisible = ref(true)
private _isOnline = ref(true)
private _lastHiddenAt = ref<number | null>(null)
private _lastVisibleAt = ref<number | null>(null)
private _isPaused = ref(false)
// Listeners
private visibilityHandler?: () => void
private onlineHandler?: () => void
private offlineHandler?: () => void
private focusHandler?: () => void
private blurHandler?: () => void
private debounceTimer?: number
private healthCheckInterval?: number
// Subscribed services that need reconnection
private subscribedServices = new Set<{
name: string
onResume: () => Promise<void>
onPause: () => Promise<void>
}>()
// Public reactive state
public readonly isVisible = computed(() => this._isVisible.value)
public readonly isOnline = computed(() => this._isOnline.value)
public readonly isPaused = computed(() => this._isPaused.value)
public readonly lastHiddenAt = computed(() => this._lastHiddenAt.value)
public readonly lastVisibleAt = computed(() => this._lastVisibleAt.value)
public readonly hiddenDuration = computed(() => {
if (!this._lastHiddenAt.value) return null
if (this._isVisible.value) {
return this._lastVisibleAt.value
? this._lastVisibleAt.value - this._lastHiddenAt.value
: null
}
return Date.now() - this._lastHiddenAt.value
})
public readonly needsReconnection = computed(() => {
const duration = this.hiddenDuration.value
return duration !== null && duration > this.reconnectThreshold
})
/**
* Service-specific initialization (called by BaseService)
*/
protected async onInitialize(): Promise<void> {
this.setupVisibilityHandlers()
this.setupNetworkHandlers()
this.setupFocusHandlers()
this.startHealthCheck()
// Set initial state
this._isVisible.value = !document?.hidden
this._isOnline.value = navigator?.onLine ?? true
this.debug('VisibilityService initialized', {
isVisible: this._isVisible.value,
isOnline: this._isOnline.value
})
}
/**
* Register a service for visibility-based lifecycle management
*/
registerService(
name: string,
onResume: () => Promise<void>,
onPause: () => Promise<void>
): () => void {
const service = { name, onResume, onPause }
this.subscribedServices.add(service)
this.debug(`Service registered: ${name}`)
// Return unregister function
return () => {
this.subscribedServices.delete(service)
this.debug(`Service unregistered: ${name}`)
}
}
/**
* Setup document visibility handlers
*/
private setupVisibilityHandlers(): void {
if (typeof document === 'undefined') return
this.visibilityHandler = () => {
// Debounce rapid changes
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.debounceTimer = setTimeout(() => {
const wasVisible = this._isVisible.value
const isNowVisible = !document.hidden
if (wasVisible === isNowVisible) return
this._isVisible.value = isNowVisible
if (isNowVisible) {
this.handleAppVisible()
} else {
this.handleAppHidden()
}
}, this.debounceDelay) as unknown as number
}
document.addEventListener('visibilitychange', this.visibilityHandler)
// Also listen for page show/hide events (iOS Safari)
window.addEventListener('pageshow', () => this.handleAppVisible())
window.addEventListener('pagehide', () => this.handleAppHidden())
}
/**
* Setup network status handlers
*/
private setupNetworkHandlers(): void {
if (typeof window === 'undefined') return
this.onlineHandler = () => {
this._isOnline.value = true
this.debug('Network online')
this.handleNetworkReconnect()
}
this.offlineHandler = () => {
this._isOnline.value = false
this.debug('Network offline')
this.handleNetworkDisconnect()
}
window.addEventListener('online', this.onlineHandler)
window.addEventListener('offline', this.offlineHandler)
}
/**
* Setup window focus handlers (desktop)
*/
private setupFocusHandlers(): void {
if (typeof window === 'undefined') return
// Window focus/blur for desktop browsers
this.focusHandler = () => {
if (!this._isVisible.value) {
this._isVisible.value = true
this.handleAppVisible()
}
}
this.blurHandler = () => {
// Don't treat blur as hidden on desktop unless document is actually hidden
if (document.hidden && this._isVisible.value) {
this._isVisible.value = false
this.handleAppHidden()
}
}
window.addEventListener('focus', this.focusHandler)
window.addEventListener('blur', this.blurHandler)
}
/**
* Handle app becoming visible
*/
private async handleAppVisible(): Promise<void> {
const now = Date.now()
this._lastVisibleAt.value = now
this._isPaused.value = false
const hiddenDuration = this._lastHiddenAt.value
? now - this._lastHiddenAt.value
: 0
this.debug('App became visible', {
hiddenDuration,
needsReconnection: hiddenDuration > this.reconnectThreshold
})
// Resume services if we were hidden long enough
if (hiddenDuration > this.reconnectThreshold) {
await this.resumeAllServices()
}
// Restart health check
this.startHealthCheck()
}
/**
* Handle app becoming hidden
*/
private async handleAppHidden(): Promise<void> {
this._lastHiddenAt.value = Date.now()
this._isPaused.value = true
this.debug('App became hidden')
// Stop health check while hidden
this.stopHealthCheck()
// Pause services after a delay (in case of quick tab switches)
setTimeout(() => {
if (this._isPaused.value) {
this.pauseAllServices()
}
}, 5000) // 5 second delay before pausing
}
/**
* Handle network reconnection
*/
private async handleNetworkReconnect(): Promise<void> {
if (!this._isVisible.value) return
this.debug('Network reconnected, resuming services')
await this.resumeAllServices()
}
/**
* Handle network disconnection
*/
private async handleNetworkDisconnect(): Promise<void> {
this.debug('Network disconnected, pausing services')
await this.pauseAllServices()
}
/**
* Resume all registered services
*/
private async resumeAllServices(): Promise<void> {
this.debug(`Resuming ${this.subscribedServices.size} services`)
const promises = Array.from(this.subscribedServices).map(async service => {
try {
await service.onResume()
this.debug(`Resumed service: ${service.name}`)
} catch (error) {
this.handleError(error, `resumeService:${service.name}`)
}
})
await Promise.allSettled(promises)
}
/**
* Pause all registered services
*/
private async pauseAllServices(): Promise<void> {
this.debug(`Pausing ${this.subscribedServices.size} services`)
const promises = Array.from(this.subscribedServices).map(async service => {
try {
await service.onPause()
this.debug(`Paused service: ${service.name}`)
} catch (error) {
this.handleError(error, `pauseService:${service.name}`)
}
})
await Promise.allSettled(promises)
}
/**
* Start health check interval
*/
private startHealthCheck(): void {
this.stopHealthCheck()
if (!this._isVisible.value) return
this.healthCheckInterval = setInterval(() => {
if (this._isVisible.value && this._isOnline.value) {
// Emit health check event for services to respond to
this.debug('Health check tick')
// Services can listen to this to verify their connections
}
}, this.checkInterval) as unknown as number
}
/**
* Stop health check interval
*/
private stopHealthCheck(): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval)
this.healthCheckInterval = undefined
}
}
/**
* Force check all connections
*/
async forceConnectionCheck(): Promise<void> {
this.debug('Forcing connection check')
await this.resumeAllServices()
}
/**
* Get current visibility state
*/
getState(): VisibilityState {
return {
isVisible: this._isVisible.value,
isOnline: this._isOnline.value,
lastHiddenAt: this._lastHiddenAt.value,
lastVisibleAt: this._lastVisibleAt.value,
hiddenDuration: this.hiddenDuration.value
}
}
/**
* Cleanup when service is disposed (called by BaseService)
*/
protected async onDispose(): Promise<void> {
// Remove all event listeners
if (typeof document !== 'undefined' && this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler)
}
if (typeof window !== 'undefined') {
if (this.onlineHandler) {
window.removeEventListener('online', this.onlineHandler)
}
if (this.offlineHandler) {
window.removeEventListener('offline', this.offlineHandler)
}
if (this.focusHandler) {
window.removeEventListener('focus', this.focusHandler)
}
if (this.blurHandler) {
window.removeEventListener('blur', this.blurHandler)
}
window.removeEventListener('pageshow', () => this.handleAppVisible())
window.removeEventListener('pagehide', () => this.handleAppHidden())
}
// Clear timers
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.stopHealthCheck()
// Clear subscribed services
this.subscribedServices.clear()
this.debug('VisibilityService disposed')
}
}
// Export singleton instance
export const visibilityService = new VisibilityService()

View file

@ -10,8 +10,9 @@ import { auth } from './auth/auth-service'
// Import PWA services
import { pwaService } from './pwa/pwa-service'
// Import payment service
// Import core services
import { paymentService } from '@/core/services/PaymentService'
import { visibilityService } from '@/core/services/VisibilityService'
/**
* Base Module Plugin
@ -34,6 +35,9 @@ export const baseModule: ModulePlugin = {
// Register payment service
container.provide(SERVICE_TOKENS.PAYMENT_SERVICE, paymentService)
// Register visibility service
container.provide(SERVICE_TOKENS.VISIBILITY_SERVICE, visibilityService)
// Register PWA service
container.provide('pwaService', pwaService)
@ -48,6 +52,10 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // PaymentService depends on AuthService
maxRetries: 3
})
await visibilityService.initialize({
waitForDependencies: false, // VisibilityService has no dependencies
maxRetries: 1
})
console.log('✅ Base module installed successfully')
},

View file

@ -64,7 +64,7 @@ export class RelayHub extends BaseService {
protected readonly metadata = {
name: 'RelayHub',
version: '1.0.0',
dependencies: [] // RelayHub has no dependencies on other services
dependencies: ['VisibilityService'] // Depends on visibility service for connection management
}
// EventEmitter functionality
@ -74,10 +74,11 @@ export class RelayHub extends BaseService {
private pool: SimplePool
private relayConfigs: Map<string, RelayConfig> = new Map()
private connectedRelays: Map<string, Relay> = new Map()
private subscriptions: Map<string, any> = new Map()
private subscriptions: Map<string, SubscriptionConfig> = new Map()
private activeSubscriptions: Map<string, any> = new Map() // Actual subscription objects
private reconnectInterval?: number
private healthCheckInterval?: number
private mobileVisibilityHandler?: () => void
private visibilityUnsubscribe?: () => void
// Connection state - we need both a reactive ref for components and internal state for business logic
public isConnected = ref(false)
@ -90,7 +91,6 @@ export class RelayHub extends BaseService {
constructor() {
super()
this.pool = new SimplePool()
this.setupMobileVisibilityHandling()
}
// Delegate to internal EventEmitter
@ -198,6 +198,10 @@ export class RelayHub extends BaseService {
this.debug('Starting connection...')
await this.connect()
this.startHealthCheck()
// Register with visibility service for connection management
this.registerWithVisibilityService()
this.debug('Initialization complete')
}
@ -289,8 +293,13 @@ export class RelayHub extends BaseService {
this.healthCheckInterval = undefined
}
// Close all subscriptions
this.subscriptions.forEach(sub => sub.close())
// Close all active subscriptions
for (const [, sub] of this.activeSubscriptions) {
if (sub && typeof sub.close === 'function') {
sub.close()
}
}
this.activeSubscriptions.clear()
this.subscriptions.clear()
// Close all relay connections
@ -302,6 +311,154 @@ export class RelayHub extends BaseService {
this.emit('disconnected')
}
/**
* Register with visibility service for connection management
*/
private registerWithVisibilityService(): void {
if (!this.visibilityService) {
this.debug('VisibilityService not available')
return
}
this.visibilityUnsubscribe = this.visibilityService.registerService(
'RelayHub',
async () => this.handleResume(),
async () => this.handlePause()
)
this.debug('Registered with VisibilityService')
}
/**
* Handle app resume (visibility restored)
*/
private async handleResume(): Promise<void> {
this.debug('Handling resume from visibility change')
// Check connection health
const disconnectedRelays = this.checkDisconnectedRelays()
if (disconnectedRelays.length > 0) {
this.debug(`Found ${disconnectedRelays.length} disconnected relays, reconnecting...`)
await this.reconnectToRelays(disconnectedRelays)
}
// Restore all subscriptions
await this.restoreSubscriptions()
// Resume health check
this.startHealthCheck()
}
/**
* Handle app pause (visibility lost)
*/
private async handlePause(): Promise<void> {
this.debug('Handling pause from visibility change')
// Stop health check while paused
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval)
this.healthCheckInterval = undefined
}
// Don't disconnect immediately - just pause health checks
// Connections will be verified when we resume
}
/**
* Check which relays have disconnected
*/
private checkDisconnectedRelays(): string[] {
const disconnected: string[] = []
for (const [url] of this.relayConfigs) {
if (!this.connectedRelays.has(url)) {
disconnected.push(url)
}
}
return disconnected
}
/**
* Reconnect to specific relays
*/
private async reconnectToRelays(relayUrls: string[]): Promise<void> {
const promises = relayUrls.map(async url => {
try {
const relay = await this.pool.ensureRelay(url)
this.connectedRelays.set(url, relay)
this.debug(`Reconnected to relay: ${url}`)
return { url, success: true }
} catch (error) {
this.debug(`Failed to reconnect to relay ${url}:`, error)
return { url, success: false }
}
})
const results = await Promise.allSettled(promises)
const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length
if (successful > 0) {
this._isConnected = true
this.isConnected.value = true
this.emit('reconnected', successful)
}
}
/**
* Restore all subscriptions after reconnection
*/
private async restoreSubscriptions(): Promise<void> {
if (this.subscriptions.size === 0) {
this.debug('No subscriptions to restore')
return
}
this.debug(`Restoring ${this.subscriptions.size} subscriptions`)
// Close old subscription objects
for (const [, sub] of this.activeSubscriptions) {
if (sub && typeof sub.close === 'function') {
sub.close()
}
}
this.activeSubscriptions.clear()
// Recreate subscriptions
for (const [id, config] of this.subscriptions) {
try {
const targetRelays = config.relays || Array.from(this.connectedRelays.keys())
const availableRelays = targetRelays.filter(url => this.connectedRelays.has(url))
if (availableRelays.length === 0) {
this.debug(`No available relays for subscription ${id}`)
continue
}
// Recreate the subscription
const subscription = this.pool.subscribeMany(availableRelays, config.filters, {
onevent: (event: Event) => {
config.onEvent?.(event)
this.emit('event', { subscriptionId: id, event, relay: 'unknown' })
},
oneose: () => {
config.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)
}
}
this.emit('subscriptionsRestored', this.subscriptions.size)
}
/**
* Subscribe to events from relays
*/
@ -336,8 +493,9 @@ export class RelayHub extends BaseService {
}
})
// Store subscription for cleanup
this.subscriptions.set(config.id, subscription)
// Store both config and active subscription
this.subscriptions.set(config.id, config)
this.activeSubscriptions.set(config.id, subscription)
// Emit subscription created event
this.emit('subscriptionCreated', { id: config.id, count: this.subscriptions.size })
@ -352,15 +510,18 @@ export class RelayHub extends BaseService {
* Unsubscribe from a specific subscription
*/
unsubscribe(subscriptionId: string): void {
const subscription = this.subscriptions.get(subscriptionId)
if (subscription) {
subscription.close()
this.subscriptions.delete(subscriptionId)
// Emit subscription removed event
this.emit('subscriptionRemoved', { id: subscriptionId, count: this.subscriptions.size })
// Close the active subscription
const activeSubscription = this.activeSubscriptions.get(subscriptionId)
if (activeSubscription && typeof activeSubscription.close === 'function') {
activeSubscription.close()
}
// Remove from both maps
this.subscriptions.delete(subscriptionId)
this.activeSubscriptions.delete(subscriptionId)
// Emit subscription removed event
this.emit('subscriptionRemoved', { id: subscriptionId, count: this.subscriptions.size })
}
/**
@ -509,41 +670,6 @@ export class RelayHub extends BaseService {
}
}
/**
* Setup mobile visibility handling for better WebSocket management
*/
private setupMobileVisibilityHandling(): void {
// Handle page visibility changes (mobile app backgrounding)
if (typeof document !== 'undefined') {
this.mobileVisibilityHandler = () => {
if (document.hidden) {
// Keep connections alive but reduce activity
} else {
console.log('Page visible, resuming normal WebSocket activity')
// Resume normal activity and check connections
this.performHealthCheck()
}
}
document.addEventListener('visibilitychange', this.mobileVisibilityHandler)
}
// Handle online/offline events
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
this.performHealthCheck()
})
window.addEventListener('offline', () => {
console.log('Network offline, marking as disconnected...')
this._isConnected = false
this.isConnected.value = false
this.emit('networkOffline')
})
}
}
/**
* Cleanup resources
@ -556,14 +682,10 @@ export class RelayHub extends BaseService {
* Cleanup when service is disposed (called by BaseService)
*/
protected async onDispose(): Promise<void> {
// Remove event listeners
if (this.mobileVisibilityHandler && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.mobileVisibilityHandler)
}
if (typeof window !== 'undefined') {
window.removeEventListener('online', () => {})
window.removeEventListener('offline', () => {})
// Unregister from visibility service
if (this.visibilityUnsubscribe) {
this.visibilityUnsubscribe()
this.visibilityUnsubscribe = undefined
}
// Disconnect and cleanup