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:
parent
8338ec2b33
commit
5a8e3688ee
4 changed files with 239 additions and 28 deletions
13
AGENTS.md
13
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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 & 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 */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue