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:
parent
3f51740f6a
commit
65d824d952
4 changed files with 178 additions and 34 deletions
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ============
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue