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 ============