From 450776f9d0a485ef34c20357114b8a8c2201c483 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 9 Oct 2025 13:37:33 +0100 Subject: [PATCH] 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 --- src/components/Bookmarks.tsx | 13 +++ src/hooks/useOfflineSync.ts | 57 +++++++++++++ src/services/offlineSyncService.ts | 126 +++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 src/hooks/useOfflineSync.ts create mode 100644 src/services/offlineSyncService.ts diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index eae87b4c..054f8efd 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -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 = ({ 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, diff --git a/src/hooks/useOfflineSync.ts b/src/hooks/useOfflineSync.ts new file mode 100644 index 00000000..c1be13eb --- /dev/null +++ b/src/hooks/useOfflineSync.ts @@ -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]) +} + diff --git a/src/services/offlineSyncService.ts b/src/services/offlineSyncService.ts new file mode 100644 index 00000000..5c1a71bb --- /dev/null +++ b/src/services/offlineSyncService.ts @@ -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 { + 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((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 +} +