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="flex items-center justify-between">
<span className="text-sm text-orange-400 font-medium">
{pendingCount} pending post{pendingCount > 1 ? 's' : ''}
{pendingCount} queued action{pendingCount > 1 ? 's' : ''}
</span>
{isSyncing && (
<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">
<li>Browse previously viewed spots</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>
</div>
)}

View file

@ -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<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> {
const { nostr } = useNostr();
@ -33,8 +57,20 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
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;
},

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 { 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 };
}

View file

@ -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<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 {
key: string;
value: string;
@ -218,10 +234,72 @@ export async function queuePost(kind: number, content: string, tags: string[][])
*/
export async function getPendingPosts(): Promise<PendingPost[]> {
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<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> {
const db = getDB();
return db.pendingPosts.where('status').equals('pending').count();
return db.pendingPosts.count();
}
// ============ Offline Detection ============