From 3e9c9bbdef8e23174d86982b5a346fb03164766e Mon Sep 17 00:00:00 2001 From: padreug Date: Fri, 5 Sep 2025 15:34:09 +0200 Subject: [PATCH] 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. --- src/core/base/BaseService.ts | 5 + src/core/di-container.ts | 3 + src/core/services/VisibilityService.ts | 390 +++++++++++++++++++++++++ src/modules/base/index.ts | 10 +- src/modules/base/nostr/relay-hub.ts | 240 +++++++++++---- 5 files changed, 588 insertions(+), 60 deletions(-) create mode 100644 src/core/services/VisibilityService.ts diff --git a/src/core/base/BaseService.ts b/src/core/base/BaseService.ts index 419db33..46cfde8 100644 --- a/src/core/base/BaseService.ts +++ b/src/core/base/BaseService.ts @@ -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 = 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 } diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 3da625e..4fb8677 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -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'), diff --git a/src/core/services/VisibilityService.ts b/src/core/services/VisibilityService.ts new file mode 100644 index 0000000..e44e3e6 --- /dev/null +++ b/src/core/services/VisibilityService.ts @@ -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(null) + private _lastVisibleAt = ref(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 + onPause: () => Promise + }>() + + // 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 { + 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, + onPause: () => Promise + ): () => 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 { + 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 { + 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 { + if (!this._isVisible.value) return + + this.debug('Network reconnected, resuming services') + await this.resumeAllServices() + } + + /** + * Handle network disconnection + */ + private async handleNetworkDisconnect(): Promise { + this.debug('Network disconnected, pausing services') + await this.pauseAllServices() + } + + /** + * Resume all registered services + */ + private async resumeAllServices(): Promise { + 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 { + 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 { + 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 { + // 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() \ No newline at end of file diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts index b4a8d62..b42cbd3 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -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') }, diff --git a/src/modules/base/nostr/relay-hub.ts b/src/modules/base/nostr/relay-hub.ts index d437456..486d3f2 100644 --- a/src/modules/base/nostr/relay-hub.ts +++ b/src/modules/base/nostr/relay-hub.ts @@ -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 = new Map() private connectedRelays: Map = new Map() - private subscriptions: Map = new Map() + private subscriptions: Map = new Map() + private activeSubscriptions: Map = 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 { + 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 { + 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 { + 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 { + 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 { - // 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