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

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