From 65d824d952a10edb96e2fe504c391a4d2cb3bc6d Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 29 Sep 2026 21:42:50 +0700 Subject: [PATCH] feat(offline): actually wire up the offline write queue lib/offlineCache already had a pendingPosts table, a sync hook and an OfflineIndicator badge, but nothing ever called queuePost - so an offline action just failed. Now it queues. - useNostrPublish: if isOffline() (no 15s publish timeout), or every relay fails, queue the ALREADY-SIGNED event and resolve. Signing happened at the moment of the action, so Amber is never re-prompted at sync time. - queueSignedEvent: supersedes older queued replaceable (0/3/10000+) and addressable (30000+) events, prunes stale ephemeral kinds (2259/24899, 15 min, a late 'I'm here' is worse than none) and caps at 100. - useSyncPendingPosts: publishes the stored signature directly (never through useNostrPublish, which would re-queue and duplicate), re-signs only legacy unsigned entries, retries every 30s, and returns failed rows so one failure can't strand an action. - A module-level lock keeps ONE publisher even though OfflineIndicator is mounted twice (mobile + desktop headers). - Indicator copy now says 'queued actions' and lists what works offline. --- src/components/OfflineIndicator.tsx | 4 +- src/hooks/useNostrPublish.ts | 42 +++++++++++++- src/hooks/useOfflineCache.ts | 78 +++++++++++++++++-------- src/lib/offlineCache.ts | 88 +++++++++++++++++++++++++++-- 4 files changed, 178 insertions(+), 34 deletions(-) diff --git a/src/components/OfflineIndicator.tsx b/src/components/OfflineIndicator.tsx index a699fe8..938e745 100644 --- a/src/components/OfflineIndicator.tsx +++ b/src/components/OfflineIndicator.tsx @@ -80,7 +80,7 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI
- {pendingCount} pending post{pendingCount > 1 ? 's' : ''} + {pendingCount} queued action{pendingCount > 1 ? 's' : ''} {isSyncing && ( @@ -104,7 +104,7 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI
  • Browse previously viewed spots
  • View cached spot details
  • -
  • Queue posts for later
  • +
  • Keep acting — posts, RSVPs, listings and messages are queued and send when you reconnect
)} diff --git a/src/hooks/useNostrPublish.ts b/src/hooks/useNostrPublish.ts index 62b2057..8bae035 100644 --- a/src/hooks/useNostrPublish.ts +++ b/src/hooks/useNostrPublish.ts @@ -2,11 +2,35 @@ import { useNostr } from "@nostrify/react"; import { useMutation, type UseMutationResult } from "@tanstack/react-query"; import { useCurrentUser } from "./useCurrentUser"; +import { isOffline, queueSignedEvent } from "@/lib/offlineCache"; +import { toast } from "@/hooks/useToast"; import type { NostrEvent, NPool } from "@nostrify/nostrify"; /** - * Publishes a Nostr event with retry logic and improved error handling + * Keep a signed event for delivery when we're back online. Signing already + * happened (above), so an interactive signer is NOT prompted again later — we + * just hand the signature over. + */ +async function queueForLater(event: NostrEvent): Promise { + try { + await queueSignedEvent(event); + toast({ + title: 'Saved offline', + description: "We'll publish it automatically when you're back online.", + }); + return true; + } catch { + return false; + } +} + +/** + * Publishes a Nostr event with retry logic and improved error handling. + * + * If the relays can't be reached, the SIGNED event is queued (see + * `lib/offlineCache`) and delivered by `useSyncPendingPosts()` on reconnect — + * so an offline action completes instead of being lost. */ export function useNostrPublish(): UseMutationResult { const { nostr } = useNostr(); @@ -33,8 +57,20 @@ export function useNostrPublish(): UseMutationResult { created_at: t.created_at ?? Math.floor(Date.now() / 1000), }); - // Publish with retry logic - await publishEventWithRetry(nostr, event); + // Offline: don't sit through a 15s publish timeout before giving up. + if (isOffline()) { + await queueForLater(event); + return event; + } + + try { + await publishEventWithRetry(nostr, event); + } catch (err) { + // Relays unreachable — keep it and deliver later, unless queuing failed + // (then the real error is the honest thing to surface). + const queued = await queueForLater(event); + if (!queued) throw err; + } return event; }, diff --git a/src/hooks/useOfflineCache.ts b/src/hooks/useOfflineCache.ts index 04d829a..ab5ea93 100644 --- a/src/hooks/useOfflineCache.ts +++ b/src/hooks/useOfflineCache.ts @@ -1,5 +1,6 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; import { cacheSpot, cacheSpots, @@ -14,10 +15,11 @@ import { queuePost, getPendingPosts, getPendingPostsCount, + prunePendingPosts, removePendingPost, updatePendingPostStatus, } from '@/lib/offlineCache'; -import { useNostrPublish } from './useNostrPublish'; +import { useCurrentUser } from './useCurrentUser'; import { useToast } from './useToast'; import type { NostrEvent } from '@nostrify/nostrify'; import type { SkateSpot } from '@/lib/types'; @@ -153,6 +155,12 @@ export function useCacheManagement() { // ============ Pending Posts Hooks ============ +/** + * Module-level lock: the queue must have exactly ONE publisher even though the + * `OfflineIndicator` (which owns the sync) is mounted twice (mobile + desktop). + */ +let globalSyncing = false; + /** * Hook to get pending posts count */ @@ -167,62 +175,84 @@ export function usePendingPostsCount() { } /** - * Hook to sync pending posts when coming back online + * Deliver queued actions when we're back online. + * + * Publishes the SIGNED event we stored (so an interactive signer is never + * re-prompted), falls back to signing legacy/unsigned entries, retries failures + * on an interval, and prunes stale ephemeral actions. Deliberately does NOT go + * through `useNostrPublish` — that would re-queue on failure and duplicate. */ export function useSyncPendingPosts() { const isOnline = useOnlineStatus(); const queryClient = useQueryClient(); - const { mutateAsync: publishEvent } = useNostrPublish(); + const { nostr } = useNostr(); + const { user } = useCurrentUser(); const { toast } = useToast(); const [isSyncing, setIsSyncing] = useState(false); + const syncingRef = useRef(false); - // Attempt to sync when coming back online useEffect(() => { - if (!isOnline || isSyncing) return; + if (!isOnline || !user) return; + let cancelled = false; const syncPosts = async () => { + // The `OfflineIndicator` is mounted twice (mobile + desktop headers), so a + // module-level lock keeps exactly one publisher at a time. + if (cancelled || syncingRef.current || globalSyncing) return; + + await prunePendingPosts().catch(() => {}); const pending = await getPendingPosts(); if (pending.length === 0) return; + if (cancelled) return; + syncingRef.current = true; + globalSyncing = true; setIsSyncing(true); let successCount = 0; - let failCount = 0; for (const post of pending) { + if (cancelled) break; try { await updatePendingPostStatus(post.id, 'publishing'); - await publishEvent({ - kind: post.kind, - content: post.content, - tags: post.tags, - }); + const event: NostrEvent = + post.signedEvent ?? + (await user.signer.signEvent({ + kind: post.kind, + content: post.content, + tags: post.tags, + created_at: Math.floor(Date.now() / 1000), + })); + await nostr.event(event, { signal: AbortSignal.timeout(15000) }); await removePendingPost(post.id); successCount++; - } catch (error) { - console.error('[OfflineSync] Failed to publish post:', error); + } catch { + // Keep it queued; the interval below will try again. await updatePendingPostStatus(post.id, 'failed', post.retries + 1); - failCount++; } } + syncingRef.current = false; + globalSyncing = false; setIsSyncing(false); queryClient.invalidateQueries({ queryKey: ['pending-posts-count'] }); - if (successCount > 0 || failCount > 0) { + if (successCount > 0) { toast({ - title: 'Syncing complete', - description: successCount > 0 - ? `Published ${successCount} queued post${successCount > 1 ? 's' : ''}` - : `Failed to publish ${failCount} post${failCount > 1 ? 's' : ''}`, - variant: failCount > 0 && successCount === 0 ? 'destructive' : 'default', + title: 'Back online', + description: `Sent ${successCount} saved action${successCount > 1 ? 's' : ''}.`, }); } }; - // Small delay before syncing to let network stabilize + // Small delay so the network settles, then keep retrying while items remain. const timeout = setTimeout(syncPosts, 2000); - return () => clearTimeout(timeout); - }, [isOnline, isSyncing, publishEvent, queryClient, toast]); + const interval = setInterval(syncPosts, 30000); + return () => { + cancelled = true; + clearTimeout(timeout); + clearInterval(interval); + }; + }, [isOnline, user, nostr, queryClient, toast]); return { isSyncing }; } diff --git a/src/lib/offlineCache.ts b/src/lib/offlineCache.ts index 06cd566..00f5c11 100644 --- a/src/lib/offlineCache.ts +++ b/src/lib/offlineCache.ts @@ -21,8 +21,24 @@ interface PendingPost { createdAt: number; status: 'pending' | 'publishing' | 'failed'; retries: number; + /** + * The ALREADY-SIGNED event, when we have it. Signing happens at the moment of + * the action (so an interactive signer like Amber is NOT re-prompted at sync + * time — the signature is just delivered later). + */ + signedEvent?: NostrEvent; + lastError?: string; } +/** + * Kinds that are only true "now" — a queued one is worse than none (e.g. a + * spot-status "I'm here" delivered 3 hours later). Dropped once stale. + */ +const EPHEMERAL_KINDS = new Set([2259, 24899]); +const EPHEMERAL_TTL_MS = 15 * 60 * 1000; +/** Hard cap so a long offline stretch can't grow the queue without bound. */ +const MAX_PENDING = 100; + interface OfflineSettings { key: string; value: string; @@ -218,10 +234,72 @@ export async function queuePost(kind: number, content: string, tags: string[][]) */ export async function getPendingPosts(): Promise { const db = getDB(); - return db.pendingPosts - .where('status') - .equals('pending') - .sortBy('createdAt'); + // Anything not currently mid-publish: 'pending' AND retryable 'failed' items + // (a single failed attempt must not strand an action forever). + const all = await db.pendingPosts.toArray(); + return all.filter((p) => p.status !== 'publishing').sort((a, b) => a.createdAt - b.createdAt); +} + +/** + * Queue an ALREADY-SIGNED event (the offline path for `useNostrPublish`). + * + * Before adding we: + * • drop queued events this one SUPERSEDES — for a replaceable (kind 0/3/10000+) + * or addressable (30000+) event, only the newest version is worth delivering; + * • drop stale ephemeral events; + * • cap the queue. + * so a long offline stretch leaves the newest, still-meaningful actions. + */ +export async function queueSignedEvent(event: NostrEvent): Promise { + const db = getDB(); + + const replaceable = event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000); + const addressable = event.kind >= 30000 && event.kind < 40000; + if (replaceable || addressable) { + const d = event.tags.find(([n]) => n === 'd')?.[1] ?? ''; + const existing = await db.pendingPosts.toArray(); + const superseded = existing + .filter((p) => { + if (p.kind !== event.kind) return false; + if (p.signedEvent && p.signedEvent.pubkey !== event.pubkey) return false; + if (!addressable) return true; + return (p.tags.find(([n]) => n === 'd')?.[1] ?? '') === d; + }) + .map((p) => p.id); + if (superseded.length > 0) await db.pendingPosts.bulkDelete(superseded); + } + + const id = crypto.randomUUID(); + await db.pendingPosts.add({ + id, + kind: event.kind, + content: event.content, + tags: event.tags, + createdAt: Date.now(), + status: 'pending', + retries: 0, + signedEvent: event, + }); + + // Keep only the newest MAX_PENDING. + const count = await db.pendingPosts.count(); + if (count > MAX_PENDING) { + const oldest = await db.pendingPosts.orderBy('createdAt').limit(count - MAX_PENDING).toArray(); + await db.pendingPosts.bulkDelete(oldest.map((p) => p.id)); + } + + return id; +} + +/** Drop queued ephemeral events that are past their useful life. */ +export async function prunePendingPosts(): Promise { + const db = getDB(); + const now = Date.now(); + const stale = (await db.pendingPosts.toArray()).filter( + (p) => EPHEMERAL_KINDS.has(p.kind) && now - p.createdAt > EPHEMERAL_TTL_MS, + ); + if (stale.length > 0) await db.pendingPosts.bulkDelete(stale.map((p) => p.id)); + return stale.length; } /** @@ -253,7 +331,7 @@ export async function removePendingPost(id: string): Promise { */ export async function getPendingPostsCount(): Promise { const db = getDB(); - return db.pendingPosts.where('status').equals('pending').count(); + return db.pendingPosts.count(); } // ============ Offline Detection ============