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.
This commit is contained in:
SkateSpots 2026-09-29 21:42:50 +07:00
parent 3f51740f6a
commit 65d824d952
4 changed files with 178 additions and 34 deletions

View file

@ -80,7 +80,7 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI
<div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30"> <div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-orange-400 font-medium"> <span className="text-sm text-orange-400 font-medium">
{pendingCount} pending post{pendingCount > 1 ? 's' : ''} {pendingCount} queued action{pendingCount > 1 ? 's' : ''}
</span> </span>
{isSyncing && ( {isSyncing && (
<Badge className="bg-orange-500 text-white text-xs"> <Badge className="bg-orange-500 text-white text-xs">
@ -104,7 +104,7 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI
<ul className="list-disc list-inside space-y-0.5"> <ul className="list-disc list-inside space-y-0.5">
<li>Browse previously viewed spots</li> <li>Browse previously viewed spots</li>
<li>View cached spot details</li> <li>View cached spot details</li>
<li>Queue posts for later</li> <li>Keep acting — posts, RSVPs, listings and messages are queued and send when you reconnect</li>
</ul> </ul>
</div> </div>
)} )}

View file

@ -2,11 +2,35 @@ import { useNostr } from "@nostrify/react";
import { useMutation, type UseMutationResult } from "@tanstack/react-query"; import { useMutation, type UseMutationResult } from "@tanstack/react-query";
import { useCurrentUser } from "./useCurrentUser"; import { useCurrentUser } from "./useCurrentUser";
import { isOffline, queueSignedEvent } from "@/lib/offlineCache";
import { toast } from "@/hooks/useToast";
import type { NostrEvent, NPool } from "@nostrify/nostrify"; 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<boolean> {
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<NostrEvent> { export function useNostrPublish(): UseMutationResult<NostrEvent> {
const { nostr } = useNostr(); const { nostr } = useNostr();
@ -33,8 +57,20 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
created_at: t.created_at ?? Math.floor(Date.now() / 1000), created_at: t.created_at ?? Math.floor(Date.now() / 1000),
}); });
// Publish with retry logic // Offline: don't sit through a 15s publish timeout before giving up.
if (isOffline()) {
await queueForLater(event);
return event;
}
try {
await publishEventWithRetry(nostr, event); 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; return event;
}, },

View file

@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { import {
cacheSpot, cacheSpot,
cacheSpots, cacheSpots,
@ -14,10 +15,11 @@ import {
queuePost, queuePost,
getPendingPosts, getPendingPosts,
getPendingPostsCount, getPendingPostsCount,
prunePendingPosts,
removePendingPost, removePendingPost,
updatePendingPostStatus, updatePendingPostStatus,
} from '@/lib/offlineCache'; } from '@/lib/offlineCache';
import { useNostrPublish } from './useNostrPublish'; import { useCurrentUser } from './useCurrentUser';
import { useToast } from './useToast'; import { useToast } from './useToast';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import type { SkateSpot } from '@/lib/types'; import type { SkateSpot } from '@/lib/types';
@ -153,6 +155,12 @@ export function useCacheManagement() {
// ============ Pending Posts Hooks ============ // ============ 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 * 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() { export function useSyncPendingPosts() {
const isOnline = useOnlineStatus(); const isOnline = useOnlineStatus();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish(); const { nostr } = useNostr();
const { user } = useCurrentUser();
const { toast } = useToast(); const { toast } = useToast();
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const syncingRef = useRef(false);
// Attempt to sync when coming back online
useEffect(() => { useEffect(() => {
if (!isOnline || isSyncing) return; if (!isOnline || !user) return;
let cancelled = false;
const syncPosts = async () => { 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(); const pending = await getPendingPosts();
if (pending.length === 0) return; if (pending.length === 0) return;
if (cancelled) return;
syncingRef.current = true;
globalSyncing = true;
setIsSyncing(true); setIsSyncing(true);
let successCount = 0; let successCount = 0;
let failCount = 0;
for (const post of pending) { for (const post of pending) {
if (cancelled) break;
try { try {
await updatePendingPostStatus(post.id, 'publishing'); await updatePendingPostStatus(post.id, 'publishing');
await publishEvent({ const event: NostrEvent =
post.signedEvent ??
(await user.signer.signEvent({
kind: post.kind, kind: post.kind,
content: post.content, content: post.content,
tags: post.tags, tags: post.tags,
}); created_at: Math.floor(Date.now() / 1000),
}));
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
await removePendingPost(post.id); await removePendingPost(post.id);
successCount++; successCount++;
} catch (error) { } catch {
console.error('[OfflineSync] Failed to publish post:', error); // Keep it queued; the interval below will try again.
await updatePendingPostStatus(post.id, 'failed', post.retries + 1); await updatePendingPostStatus(post.id, 'failed', post.retries + 1);
failCount++;
} }
} }
syncingRef.current = false;
globalSyncing = false;
setIsSyncing(false); setIsSyncing(false);
queryClient.invalidateQueries({ queryKey: ['pending-posts-count'] }); queryClient.invalidateQueries({ queryKey: ['pending-posts-count'] });
if (successCount > 0 || failCount > 0) { if (successCount > 0) {
toast({ toast({
title: 'Syncing complete', title: 'Back online',
description: successCount > 0 description: `Sent ${successCount} saved action${successCount > 1 ? 's' : ''}.`,
? `Published ${successCount} queued post${successCount > 1 ? 's' : ''}`
: `Failed to publish ${failCount} post${failCount > 1 ? 's' : ''}`,
variant: failCount > 0 && successCount === 0 ? 'destructive' : 'default',
}); });
} }
}; };
// 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); const timeout = setTimeout(syncPosts, 2000);
return () => clearTimeout(timeout); const interval = setInterval(syncPosts, 30000);
}, [isOnline, isSyncing, publishEvent, queryClient, toast]); return () => {
cancelled = true;
clearTimeout(timeout);
clearInterval(interval);
};
}, [isOnline, user, nostr, queryClient, toast]);
return { isSyncing }; return { isSyncing };
} }

View file

@ -21,8 +21,24 @@ interface PendingPost {
createdAt: number; createdAt: number;
status: 'pending' | 'publishing' | 'failed'; status: 'pending' | 'publishing' | 'failed';
retries: number; 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<number>([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 { interface OfflineSettings {
key: string; key: string;
value: string; value: string;
@ -218,10 +234,72 @@ export async function queuePost(kind: number, content: string, tags: string[][])
*/ */
export async function getPendingPosts(): Promise<PendingPost[]> { export async function getPendingPosts(): Promise<PendingPost[]> {
const db = getDB(); const db = getDB();
return db.pendingPosts // Anything not currently mid-publish: 'pending' AND retryable 'failed' items
.where('status') // (a single failed attempt must not strand an action forever).
.equals('pending') const all = await db.pendingPosts.toArray();
.sortBy('createdAt'); 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<string> {
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<number> {
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<void> {
*/ */
export async function getPendingPostsCount(): Promise<number> { export async function getPendingPostsCount(): Promise<number> {
const db = getDB(); const db = getDB();
return db.pendingPosts.where('status').equals('pending').count(); return db.pendingPosts.count();
} }
// ============ Offline Detection ============ // ============ Offline Detection ============