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