From 5a8e3688ee7b1377b738a7a52aef9fd7f71ab20f Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 29 Sep 2026 22:20:50 +0700 Subject: [PATCH] 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). --- AGENTS.md | 13 +++++ src/hooks/useStash.ts | 102 +++++++++++++++++++++++++++---------- src/lib/offlineCache.ts | 44 +++++++++++++++- src/pages/StashPage.tsx | 108 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 239 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87380e1..ae4fd70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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), diff --git a/src/hooks/useStash.ts b/src/hooks/useStash.ts index 38c80b1..5ff9bbf 100644 --- a/src/hooks/useStash.ts +++ b/src/hooks/useStash.ts @@ -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', 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>) => { + 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, }; } diff --git a/src/lib/offlineCache.ts b/src/lib/offlineCache.ts index 00f5c11..03fa160 100644 --- a/src/lib/offlineCache.ts +++ b/src/lib/offlineCache.ts @@ -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; pendingPosts!: Table; settings!: Table; + stash!: Table; 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 { 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 { + 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 { + const db = getDB(); + try { + const row = await db.stash.get(pubkey); + return row?.event; + } catch { + return undefined; + } +} + +export async function clearCachedStash(pubkey: string): Promise { + const db = getDB(); + await db.stash.delete(pubkey); +} + +// ============ Offline Detection ============ /** * Check if the app is currently offline */ diff --git a/src/pages/StashPage.tsx b/src/pages/StashPage.tsx index 3356a4d..f1d869f 100644 --- a/src/pages/StashPage.tsx +++ b/src/pages/StashPage.tsx @@ -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(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 => + !!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 = ( +
+
+ + Backup & transfer +
+

+ Your stash is encrypted on Nostr. Keep a copy you control — a dead relay or a new + account can't lose your gear list. +

+
+ + +
+ { + const f = e.target.files?.[0]; + if (f) void handleImportFile(f); + e.target.value = ''; + }} + /> +
+ ); // Add: save to encrypted stash, then publish a public event if it's public. const handleAddItem = (item: Omit) => { @@ -1079,6 +1179,9 @@ export default function StashPage() { )} + {/* Backup & transfer */} + {backupCard} + {/* How it works */}

How it works

@@ -1115,6 +1218,9 @@ export default function StashPage() {
)} + + {/* Backup & transfer — mobile (the sidebar is desktop-only). */} +
{backupCard}
{/* Mobile FABs */}