feat(stash): readable offline + export/import backup

The private stash was deny-listed from persistence with no local cache, so an
offline or relay-outage cold start showed an EMPTY stash even though decrypting
needs no network.

- offlineCache v2 adds a 'stash' table storing the encrypted CIPHERTEXT (never
  the decrypted list). useStash caches it on every successful fetch, and both
  the read query and fetchLatestStash (the pre-write re-fetch) fall back to it --
  so the stash loads at the park and an offline edit rebuilds from the real list
  instead of throwing.
- /stash gains a 'Backup & transfer' card (desktop sidebar + mobile) with
  Export (a JSON you control) and Import (one re-encrypt, skipping
  title+category+size duplicates).
This commit is contained in:
SkateSpots 2026-09-29 22:20:50 +07:00
parent 8338ec2b33
commit 5a8e3688ee
4 changed files with 239 additions and 28 deletions

View file

@ -782,6 +782,19 @@ session with **no other memory of this project**. So:
while offline→online. ⚠️ `useNostrPublish` now **resolves** (instead of
throwing) when it queues — callers show their success toast, corrected by a
global "Saved offline" toast + the badge.
- **Stash is readable offline + backed up (2026-09-29).** The private stash
(NIP-51 kind `30001`, `d=skatespots-stash`, NIP-44 encrypted to self) was
**deny-listed from React Query persistence with NO local cache**, so an
offline/relay-outage cold start showed an EMPTY stash even though decryption
needs no network. Now the **encrypted CIPHERTEXT** is cached in Dexie
(`offlineCache` v2 `stash` table — `cacheStashEvent`/`getCachedStashEvent`),
and both the read query and `fetchLatestStash` (the pre-write re-fetch) fall
back to it — so the stash still loads at the park and an offline edit rebuilds
from the real list instead of throwing. **Only ciphertext is stored, never the
decrypted list.** Also added **Export/Import** on `/stash` (`Backup &
transfer` card, desktop sidebar + mobile): export writes a JSON you control;
import appends in ONE re-encrypt, skipping title+category+size duplicates
(`importItems`).
- **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie
`SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)
+ `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it),

View file

@ -5,6 +5,8 @@ import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import { cacheStashEvent, getCachedStashEvent } from '@/lib/offlineCache';
import type { NostrEvent } from '@nostrify/nostrify';
// Stash item stored in encrypted NIP-51 list
export interface StashItem {
@ -77,32 +79,40 @@ export function useStash(options?: { enabled?: boolean }) {
if (!user?.pubkey || !hasNip44) {
return { items: [], updatedAt: 0 };
}
const pubkey = user.pubkey;
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
// Fetch the encrypted stash event
const events = await nostr.query([{
kinds: [STASH_KIND],
authors: [user.pubkey],
'#d': [STASH_D_TAG],
limit: 1,
}], { signal: combinedSignal });
// Decrypt handle first — an absent/broken signer means "no stash", not a crash.
let nip44Dec: typeof user.signer.nip44;
try { nip44Dec = user.signer?.nip44; } catch { return { items: [], updatedAt: 0 }; }
if (!nip44Dec) return { items: [], updatedAt: 0 };
if (events.length === 0) {
return { items: [], updatedAt: 0 };
// Prefer the live event; if the relays are unreachable (offline / outage)
// fall back to the encrypted copy we cached locally — decrypting needs no
// network, so the stash still works at the skatepark.
let event: NostrEvent | undefined;
try {
const events = await nostr.query([{
kinds: [STASH_KIND],
authors: [pubkey],
'#d': [STASH_D_TAG],
limit: 1,
}], { signal: combinedSignal });
event = events[0];
} catch {
// offline — the cache below covers it
}
if (!event) event = await getCachedStashEvent(pubkey);
if (!event) return { items: [], updatedAt: 0 };
const event = events[0];
// Keep the ciphertext for next time.
cacheStashEvent(pubkey, event).catch(() => {});
try {
// Decrypt the content (encrypted to self) — access nip44 inside try/catch
// to guard against extension signers throwing when window.nostr is gone
let nip44Dec: typeof user.signer.nip44;
try { nip44Dec = user.signer?.nip44; } catch { return { items: [], updatedAt: 0 }; }
if (!nip44Dec) return { items: [], updatedAt: 0 };
const decrypted = await nip44Dec.decrypt(user.pubkey, event.content);
const stashData = JSON.parse(decrypted) as Stash;
return stashData;
// Decrypt the content (encrypted to self).
const decrypted = await nip44Dec.decrypt(pubkey, event.content);
return JSON.parse(decrypted) as Stash;
} catch (err) {
console.error('[Stash] Failed to decrypt stash:', err);
return { items: [], updatedAt: 0 };
@ -147,14 +157,24 @@ export function useStash(options?: { enabled?: boolean }) {
}
try {
const events = await nostr.query([{
kinds: [STASH_KIND],
authors: [user.pubkey],
'#d': [STASH_D_TAG],
limit: 1,
}], { signal: AbortSignal.timeout(10000) });
// Live event first; fall back to the locally cached ciphertext when the
// relays are unreachable (so an offline edit still rebuilds from the real
// list instead of throwing / clobbering).
let event: NostrEvent | undefined;
try {
const events = await nostr.query([{
kinds: [STASH_KIND],
authors: [user.pubkey],
'#d': [STASH_D_TAG],
limit: 1,
}], { signal: AbortSignal.timeout(10000) });
event = events[0];
} catch {
// offline — fall through to the cache
}
if (!event) event = await getCachedStashEvent(user.pubkey);
if (events.length === 0) {
if (!event) {
// No remote event: safe to start fresh only if we also have nothing cached.
return (
queryClient.getQueryData<Stash>(['stash', user.pubkey]) ??
@ -162,7 +182,8 @@ export function useStash(options?: { enabled?: boolean }) {
);
}
const decrypted = await nip44Dec.decrypt(user.pubkey, events[0].content);
cacheStashEvent(user.pubkey, event).catch(() => {});
const decrypted = await nip44Dec.decrypt(user.pubkey, event.content);
return JSON.parse(decrypted) as Stash;
} catch (err) {
// On any failure, prefer cached data over an empty list so we never wipe.
@ -259,6 +280,34 @@ export function useStash(options?: { enabled?: boolean }) {
},
});
// Bulk import (Export/Import) — append items, skipping obvious duplicates, in
// ONE re-encrypt + publish rather than N.
const importItems = useMutation({
mutationFn: async (items: Array<Omit<StashItem, 'id' | 'addedAt'>>) => {
const currentStash = await fetchLatestStash();
const keyOf = (i: { title: string; category: string; size?: string }) =>
`${i.title.trim().toLowerCase()}|${i.category}|${(i.size ?? '').trim().toLowerCase()}`;
const have = new Set(currentStash.items.map(keyOf));
const now = Math.floor(Date.now() / 1000);
const fresh: StashItem[] = [];
for (const raw of items) {
if (!raw?.title || !raw?.category) continue;
const key = keyOf({ title: raw.title, category: raw.category, size: raw.size });
if (have.has(key)) continue;
have.add(key);
fresh.push({ ...raw, id: crypto.randomUUID(), addedAt: now });
}
if (fresh.length === 0) return { added: 0, skipped: items.length };
await saveStash({ items: [...currentStash.items, ...fresh], updatedAt: now });
return { added: fresh.length, skipped: items.length - fresh.length };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Stats for RPG display
const stats = useMemo(() => {
if (!stash) return { total: 0, byCategory: {}, totalValue: 0 };
@ -287,6 +336,7 @@ export function useStash(options?: { enabled?: boolean }) {
addItem,
updateItem,
removeItem,
importItems,
isEncryptionAvailable: hasNip44,
};
}

View file

@ -44,11 +44,23 @@ interface OfflineSettings {
value: string;
}
/**
* The encrypted stash event, kept on device so the stash is still readable
* offline (and during a relay outage). We deliberately cache the CIPHERTEXT —
* exactly what relays already hold — never the decrypted list.
*/
interface CachedStashEvent {
pubkey: string;
event: NostrEvent;
cachedAt: number;
}
// Create the Dexie database
class SkateSpotsCacheDB extends Dexie {
spots!: Table<CachedSpot, string>;
pendingPosts!: Table<PendingPost, string>;
settings!: Table<OfflineSettings, string>;
stash!: Table<CachedStashEvent, string>;
constructor() {
super('SkateSpotsCacheDB');
@ -57,6 +69,10 @@ class SkateSpotsCacheDB extends Dexie {
pendingPosts: 'id, kind, status, createdAt',
settings: 'key',
});
// v2 adds the encrypted-stash cache (see CachedStashEvent).
this.version(2).stores({
stash: 'pubkey, cachedAt',
});
}
}
@ -334,8 +350,34 @@ export async function getPendingPostsCount(): Promise<number> {
return db.pendingPosts.count();
}
// ============ Offline Detection ============
// ============ Encrypted Stash Cache (offline readability) ============
/**
* Remember the encrypted stash event so it can be decrypted offline. Stores the
* CIPHERTEXT only — never the decrypted list.
*/
export async function cacheStashEvent(pubkey: string, event: NostrEvent): Promise<void> {
const db = getDB();
await db.stash.put({ pubkey, event, cachedAt: Date.now() });
}
/** The last encrypted stash event we saw for this pubkey, if any. */
export async function getCachedStashEvent(pubkey: string): Promise<NostrEvent | undefined> {
const db = getDB();
try {
const row = await db.stash.get(pubkey);
return row?.event;
} catch {
return undefined;
}
}
export async function clearCachedStash(pubkey: string): Promise<void> {
const db = getDB();
await db.stash.delete(pubkey);
}
// ============ Offline Detection ============
/**
* Check if the app is currently offline
*/

View file

@ -54,7 +54,11 @@ import {
MapPin,
LayoutGrid,
List,
Download,
Upload,
HardDrive,
} from 'lucide-react';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
// ─── Rarity helpers ──────────────────────────────────────────────────────────
@ -601,8 +605,104 @@ export default function StashPage() {
const navigate = useNavigate();
const { user } = useCurrentUser();
const { items, stats, isLoading, addItem, updateItem, removeItem, isEncryptionAvailable } = useStash();
const { items, stats, isLoading, addItem, updateItem, removeItem, importItems, isEncryptionAvailable } = useStash();
const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
// ── Backup & transfer: your stash is encrypted on Nostr, but a local copy you
// control means a dead relay (or a new account) can't lose your gear list. ──
const handleExport = () => {
const payload = {
app: 'SkateSpots',
type: 'stash-export',
version: 1,
exportedAt: new Date().toISOString(),
items,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `skatespots-stash-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast({ title: 'Stash exported', description: `${items.length} item${items.length === 1 ? '' : 's'} saved to a file.` });
};
const handleImportFile = async (file: File) => {
try {
const parsed = JSON.parse(await file.text()) as unknown;
const raw = Array.isArray(parsed) ? parsed : (parsed as { items?: unknown })?.items;
if (!Array.isArray(raw)) throw new Error('No items found in that file.');
const cleaned = raw.filter(
(i): i is Omit<StashItem, 'id' | 'addedAt'> =>
!!i &&
typeof i === 'object' &&
typeof (i as { title?: unknown }).title === 'string' &&
typeof (i as { category?: unknown }).category === 'string',
);
if (cleaned.length === 0) throw new Error('No valid items found in that file.');
const res = await importItems.mutateAsync(cleaned);
toast({
title: `Imported ${res.added} item${res.added === 1 ? '' : 's'}`,
description: res.skipped > 0 ? `${res.skipped} skipped (already in your stash).` : 'Added to your stash.',
});
} catch (e) {
toast({
title: 'Import failed',
description: e instanceof Error ? e.message : 'Could not read that file.',
variant: 'destructive',
});
}
};
const backupCard = (
<div className="space-y-3 rounded-2xl border border-white/6 bg-white/2 p-4">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-[#ff6600]" />
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">Backup &amp; transfer</span>
</div>
<p className="text-[11px] leading-snug text-gray-500">
Your stash is encrypted on Nostr. Keep a copy you control — a dead relay or a new
account can't lose your gear list.
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1 gap-1.5 border-white/15"
onClick={handleExport}
disabled={items.length === 0}
>
<Download className="h-3.5 w-3.5" /> Export
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 gap-1.5 border-white/15"
onClick={() => fileInputRef.current?.click()}
disabled={importItems.isPending}
>
{importItems.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
Import
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleImportFile(f);
e.target.value = '';
}}
/>
</div>
);
// Add: save to encrypted stash, then publish a public event if it's public.
const handleAddItem = (item: Omit<StashItem, 'id' | 'addedAt'>) => {
@ -1079,6 +1179,9 @@ export default function StashPage() {
</div>
)}
{/* Backup & transfer */}
{backupCard}
{/* How it works */}
<div className="rounded-2xl border border-white/6 bg-white/2 p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-3">How it works</p>
@ -1115,6 +1218,9 @@ export default function StashPage() {
</Link>
</div>
)}
{/* Backup & transfer — mobile (the sidebar is desktop-only). */}
<div className="lg:hidden mt-4">{backupCard}</div>
</div>
{/* Mobile FABs */}