feat: automatic offline sync - rebroadcast local events when back online

- Create offlineSyncService to sync local-only events to remote relays
- Create useOfflineSync hook to detect online/offline transitions
- When user comes back online (remote relays connect), automatically:
  - Query local relays for user's events from last 24 hours
  - Rebroadcast highlights and bookmarks to remote relays
- Integrate sync into Bookmarks component
- Enables seamless offline workflow:
  - User can work offline with local relays
  - Events are automatically synced when connection restored
  - No manual intervention required
This commit is contained in:
Gigi
2025-10-09 13:37:33 +01:00
parent 0478713fd5
commit 450776f9d0
3 changed files with 196 additions and 0 deletions

View File

@@ -10,6 +10,8 @@ import { useBookmarksData } from '../hooks/useBookmarksData'
import { useContentSelection } from '../hooks/useContentSelection'
import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout'
import { classifyHighlights } from '../utils/highlightClassification'
@@ -50,6 +52,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager
})
// Monitor relay status for offline sync
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
// Automatically sync local events to remote relays when coming back online
useOfflineSync({
relayPool,
account: activeAccount,
relayStatuses,
enabled: true
})
const {
isCollapsed,
setIsCollapsed,

View File

@@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-core/helpers'
import { syncLocalEventsToRemote } from '../services/offlineSyncService'
import { isLocalRelay } from '../utils/helpers'
import { RelayStatus } from '../services/relayStatusService'
interface UseOfflineSyncParams {
relayPool: RelayPool | null
account: IAccount | null
relayStatuses: RelayStatus[]
enabled?: boolean
}
export function useOfflineSync({
relayPool,
account,
relayStatuses,
enabled = true
}: UseOfflineSyncParams) {
const previousStateRef = useRef<{
hasRemoteRelays: boolean
initialized: boolean
}>({
hasRemoteRelays: false,
initialized: false
})
useEffect(() => {
if (!enabled || !relayPool || !account) return
const connectedRelays = relayStatuses.filter(r => r.isInPool)
const hasRemoteRelays = connectedRelays.some(r => !isLocalRelay(r.url))
const hasLocalRelays = connectedRelays.some(r => isLocalRelay(r.url))
// Skip the first check to avoid syncing on initial load
if (!previousStateRef.current.initialized) {
previousStateRef.current = {
hasRemoteRelays,
initialized: true
}
return
}
// Detect transition: from local-only to having remote relays
const wasLocalOnly = !previousStateRef.current.hasRemoteRelays && hasLocalRelays
const isNowOnline = hasRemoteRelays
if (wasLocalOnly && isNowOnline) {
console.log('✈️ Detected transition: Flight Mode → Online')
syncLocalEventsToRemote(relayPool, account, true)
}
previousStateRef.current.hasRemoteRelays = hasRemoteRelays
}, [relayPool, account, relayStatuses, enabled])
}

View File

@@ -0,0 +1,126 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IAccount } from 'applesauce-core/helpers'
import { RELAYS } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
interface SyncState {
lastRemoteConnectionTime: number
wasLocalOnly: boolean
isSyncing: boolean
}
const syncState: SyncState = {
lastRemoteConnectionTime: 0,
wasLocalOnly: false,
isSyncing: false
}
/**
* Syncs local-only events to remote relays when coming back online
*/
export async function syncLocalEventsToRemote(
relayPool: RelayPool,
account: IAccount | null,
hasRemoteRelays: boolean
): Promise<void> {
if (!account || syncState.isSyncing) return
// Detect transition from local-only to having remote relays
const justCameOnline = !syncState.wasLocalOnly && hasRemoteRelays
syncState.wasLocalOnly = !hasRemoteRelays
if (!justCameOnline) return
console.log('🔄 Coming back online - syncing local events to remote relays...')
syncState.isSyncing = true
try {
const localRelays = RELAYS.filter(isLocalRelay)
const remoteRelays = RELAYS.filter(url => !isLocalRelay(url))
if (localRelays.length === 0 || remoteRelays.length === 0) {
console.log('⚠️ No local or remote relays available for sync')
return
}
// Get events from local relays that were created in the last 24 hours
const since = Math.floor(Date.now() / 1000) - (24 * 60 * 60)
const eventsToSync: NostrEvent[] = []
// Query for user's events from local relays
const filters = [
{ kinds: [9802], authors: [account.pubkey], since }, // Highlights
{ kinds: [10003, 30003], authors: [account.pubkey], since }, // Bookmarks
]
for (const filter of filters) {
const events = await new Promise<NostrEvent[]>((resolve) => {
const collected: NostrEvent[] = []
const sub = relayPool.req(localRelays, filter, {
onevent: (event: NostrEvent) => {
collected.push(event)
},
oneose: () => {
sub.close()
resolve(collected)
}
})
// Timeout after 5 seconds
setTimeout(() => {
sub.close()
resolve(collected)
}, 5000)
})
eventsToSync.push(...events)
}
if (eventsToSync.length === 0) {
console.log('✅ No local events to sync')
syncState.isSyncing = false
return
}
// Deduplicate events by id
const uniqueEvents = Array.from(
new Map(eventsToSync.map(e => [e.id, e])).values()
)
console.log(`📤 Syncing ${uniqueEvents.length} event(s) to remote relays...`)
// Publish to remote relays
let successCount = 0
for (const event of uniqueEvents) {
try {
await relayPool.publish(remoteRelays, event)
successCount++
} catch (error) {
console.warn(`⚠️ Failed to sync event ${event.id.slice(0, 8)}:`, error)
}
}
console.log(`✅ Synced ${successCount}/${uniqueEvents.length} events to remote relays`)
syncState.lastRemoteConnectionTime = Date.now()
} catch (error) {
console.error('❌ Error during offline sync:', error)
} finally {
syncState.isSyncing = false
}
}
/**
* Checks if we should sync based on current relay state
*/
export function shouldSync(
hasRemoteRelays: boolean,
account: IAccount | null
): boolean {
if (!account || syncState.isSyncing) return false
// Only sync if we just came online (transition from local-only to having remote relays)
const justCameOnline = syncState.wasLocalOnly && hasRemoteRelays
return justCameOnline
}