SkateSpots/src/hooks/useStash.ts
shakespeare.diy 29d41b81eb Stash: prevent data-loss clobber on save + confirm before deleting gear
Audited the Stash — a private, NIP-44-encrypted, Amber-sensitive surface.

DATA-SAFETY FIX (the important one):
The stash is a single replaceable event whose encrypted content is the WHOLE
item list, so every add/update/remove rewrites the entire event. The mutations
built the new list from the `stash` closure value — but that's legitimately
`undefined` before the decrypt completes, and with Amber the read query is
gated / staleTime:Infinity, so it may never have been decrypted at mutation
time. Result: adding one item while `stash` was empty/undefined could publish a
list containing only that item and silently WIPE all existing gear.

Fix: mutations now call `fetchLatestStash()` — a fresh relay fetch + decrypt of
the current stash immediately before saving — and build the new list on top of
that. It falls back to React Query cache (never an empty list) on failure, and
throws rather than risk a clobber if it truly can't load. saveStash also writes
the just-published value straight into the cache to keep reads consistent.

The extra decrypt per mutation is an acceptable Amber tradeoff: a mutation is
already a deliberate write (encrypt + sign prompt), and data integrity beats
prompt count.

UX:
• Delete now asks for confirmation via an AlertDialog (item name + a note that
  public items get unlisted). Previously a single tap wiped an item outright —
  destructive and, with Amber, a surprise signer prompt.

The rest of StashPage (login gate, encryption-required state, distinct
wishlist/stash/filtered empty states, skeletons, rarity cards, grid/list) is
already excellent — left as-is.

Bumps to 1.2.23 (Android versionCode 10223 auto-derived).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-07 20:31:48 +07:00

360 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
// Stash item stored in encrypted NIP-51 list
export interface StashItem {
id: string;
title: string;
category: 'deck' | 'trucks' | 'wheels' | 'bearings' | 'shoes' | 'clothing' | 'protective' | 'complete' | 'other';
condition: 'new' | 'like_new' | 'good' | 'well_used';
size?: string;
brand?: string;
description?: string;
minPrice?: number; // Minimum sats willing to accept
maxPrice?: number; // Maximum sats willing to pay (for WTB)
images?: string[];
willingToTrade?: boolean;
willingToSell?: boolean;
addedAt: number;
// Visibility settings
visibility: 'private' | 'friends' | 'public';
// Optional geohash for local matching
geohash?: string;
// Item type: regular stash item or Want To Buy
itemType?: 'stash' | 'wtb';
// Rarity override (for special items)
rarity?: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
// Physical storage location
storageLocation?: 'home' | 'car' | 'skatepark' | 'warehouse' | 'storage_unit' | 'van' | 'friends' | 'parents' | 'wardrobe' | 'backpack' | 'sponsor' | 'other';
}
export interface Stash {
items: StashItem[];
updatedAt: number;
}
// Kind 30001 is NIP-51 "Follow sets" - we use a private encrypted list
const STASH_KIND = 30001;
const STASH_D_TAG = 'skatespots-stash';
/**
* Hook to manage user's private gear stash
* Uses NIP-44 encryption to store items privately
*/
/**
* @param options.enabled When false, the stash is NOT fetched/decrypted. Used
* by callers that mount this hook only for its data-access API (e.g. the
* global Sasquatch assistant via usePersonalContext) but must NOT trigger a
* decrypt — with Amber every decrypt is a popup, and this hook used to fire on
* every page because Sasquatch is mounted in the app-wide Layout. Defaults to
* true so the actual Stash page still loads normally. When disabled, `stash`
* still resolves from any previously-cached decrypt (React Query cache).
*/
export function useStash(options?: { enabled?: boolean }) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const queryClient = useQueryClient();
const { mutateAsync: createEvent } = useNostrPublish();
const callerEnabled = options?.enabled ?? true;
// Determine if nip44 is safely available WITHOUT touching any throwing getter.
const hasNip44 = useMemo(() => checkNip44(user, logins), [user, logins]);
// Amber prompts on the stash decrypt — keep it from re-running on window focus
// (e.g. every time Amber hands focus back) so it decrypts once, not repeatedly.
const interactive = isInteractiveSigner(user, logins);
// Fetch and decrypt stash
const { data: stash, isLoading, error } = useQuery({
queryKey: ['stash', user?.pubkey],
queryFn: async ({ signal }): Promise<Stash> => {
if (!user?.pubkey || !hasNip44) {
return { items: [], updatedAt: 0 };
}
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 });
if (events.length === 0) {
return { items: [], updatedAt: 0 };
}
const event = events[0];
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;
} catch (err) {
console.error('[Stash] Failed to decrypt stash:', err);
return { items: [], updatedAt: 0 };
}
},
enabled: callerEnabled && !!user?.pubkey && hasNip44,
staleTime: interactive ? Infinity : 60000,
refetchOnWindowFocus: false,
});
/**
* Re-fetch + decrypt the freshest stash from relays RIGHT BEFORE a mutation,
* rather than trusting the `stash` closure value.
*
* Why this matters (data-safety): the stash is one replaceable event whose
* content is the WHOLE list. Every add/update/remove rewrites that entire
* event. If we based the new list on a stale or not-yet-decrypted `stash`
* (which is legitimately `undefined` early, and — with Amber — may never have
* been decrypted because the query is gated / `staleTime: Infinity`), we'd
* publish a list missing everything the closure didn't know about and
* silently wipe the user's gear. So we always rebuild from a fresh decrypt.
*
* Falls back to the cached query data, then to an empty stash, only if the
* live fetch genuinely returns nothing.
*/
const fetchLatestStash = useCallback(async (): Promise<Stash> => {
if (!user?.pubkey || !hasNip44) return { items: [], updatedAt: 0 };
let nip44Dec: typeof user.signer.nip44;
try { nip44Dec = user.signer?.nip44; } catch { nip44Dec = undefined; }
if (!nip44Dec) {
// Can't decrypt now — use whatever we already have cached to avoid clobber.
return (
queryClient.getQueryData<Stash>(['stash', user.pubkey]) ??
stash ?? { items: [], updatedAt: 0 }
);
}
try {
const events = await nostr.query([{
kinds: [STASH_KIND],
authors: [user.pubkey],
'#d': [STASH_D_TAG],
limit: 1,
}], { signal: AbortSignal.timeout(10000) });
if (events.length === 0) {
// No remote event: safe to start fresh only if we also have nothing cached.
return (
queryClient.getQueryData<Stash>(['stash', user.pubkey]) ??
stash ?? { items: [], updatedAt: 0 }
);
}
const decrypted = await nip44Dec.decrypt(user.pubkey, events[0].content);
return JSON.parse(decrypted) as Stash;
} catch (err) {
// On any failure, prefer cached data over an empty list so we never wipe.
console.error('[Stash] Failed to re-fetch latest stash before save:', err);
const cached = queryClient.getQueryData<Stash>(['stash', user.pubkey]) ?? stash;
if (cached) return cached;
// Nothing to fall back on — surface the error instead of risking a clobber.
throw err instanceof Error ? err : new Error('Could not load your stash to update it safely.');
}
}, [user, hasNip44, nostr, queryClient, stash]);
// Save stash (encrypt and publish)
const saveStash = useCallback(async (newStash: Stash) => {
if (!user?.pubkey || !hasNip44) {
throw new Error('Encryption not available');
}
// Access nip44 inside a try/catch at call time (signer is guaranteed by hasNip44 above)
let nip44Enc: typeof user.signer.nip44;
try { nip44Enc = user.signer?.nip44; } catch { throw new Error('Encryption not available'); }
if (!nip44Enc) throw new Error('Encryption not available');
// Encrypt to self
const encrypted = await nip44Enc.encrypt(user.pubkey, JSON.stringify(newStash));
// Publish as replaceable event
await createEvent({
kind: STASH_KIND,
content: encrypted,
tags: [
['d', STASH_D_TAG],
['alt', 'Private gear stash (encrypted)'],
],
});
// Keep the cache in sync with exactly what we just published, then invalidate.
queryClient.setQueryData(['stash', user.pubkey], newStash);
queryClient.invalidateQueries({ queryKey: ['stash', user.pubkey] });
}, [user, hasNip44, createEvent, queryClient]);
// Add item mutation
const addItem = useMutation({
mutationFn: async (item: Omit<StashItem, 'id' | 'addedAt'>) => {
const currentStash = await fetchLatestStash();
const newItem: StashItem = {
...item,
id: crypto.randomUUID(),
addedAt: Math.floor(Date.now() / 1000),
};
const newStash: Stash = {
items: [...currentStash.items, newItem],
updatedAt: Math.floor(Date.now() / 1000),
};
await saveStash(newStash);
return newItem;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Update item mutation
const updateItem = useMutation({
mutationFn: async (item: StashItem) => {
const currentStash = await fetchLatestStash();
const newStash: Stash = {
items: currentStash.items.map(i => i.id === item.id ? item : i),
updatedAt: Math.floor(Date.now() / 1000),
};
await saveStash(newStash);
return item;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Remove item mutation
const removeItem = useMutation({
mutationFn: async (itemId: string) => {
const currentStash = await fetchLatestStash();
const newStash: Stash = {
items: currentStash.items.filter(i => i.id !== itemId),
updatedAt: Math.floor(Date.now() / 1000),
};
await saveStash(newStash);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Stats for RPG display
const stats = useMemo(() => {
if (!stash) return { total: 0, byCategory: {}, totalValue: 0 };
const byCategory: Record<string, number> = {};
let totalValue = 0;
for (const item of stash.items) {
byCategory[item.category] = (byCategory[item.category] || 0) + 1;
if (item.minPrice) totalValue += item.minPrice;
}
return {
total: stash.items.length,
byCategory,
totalValue,
};
}, [stash]);
return {
stash,
items: stash?.items || [],
stats,
isLoading,
error,
addItem,
updateItem,
removeItem,
isEncryptionAvailable: hasNip44,
};
}
// Category metadata for RPG display
export const STASH_CATEGORIES = {
deck: { emoji: '🛹', label: 'Decks', color: '#ff0040', rarity: 'common' },
trucks: { emoji: '🔧', label: 'Trucks', color: '#ff6600', rarity: 'common' },
wheels: { emoji: 'âš™ï¸<C3AF>', label: 'Wheels', color: '#ccff00', rarity: 'common' },
bearings: { emoji: '🔩', label: 'Bearings', color: '#00ff88', rarity: 'uncommon' },
shoes: { emoji: '👟', label: 'Shoes', color: '#00ccff', rarity: 'uncommon' },
clothing: { emoji: '👕', label: 'Clothing', color: '#9933ff', rarity: 'common' },
protective: { emoji: '🛡ï¸<C3AF>', label: 'Protective', color: '#ff33cc', rarity: 'rare' },
complete: { emoji: '🎯', label: 'Completes', color: '#ffd700', rarity: 'epic' },
other: { emoji: '📦', label: 'Other', color: '#888888', rarity: 'common' },
} as const;
export const CONDITION_LABELS = {
new: { label: '🆕 New', color: '#00ff00', stars: 5 },
like_new: { label: '✨ Like New', color: '#ccff00', stars: 4 },
good: { label: 'ðŸ‘<C5B8> Good', color: '#ffcc00', stars: 3 },
well_used: { label: 'âš ï¸<C3AF> Well Used', color: '#ff6600', stars: 2 },
} as const;
// RPG-style rarity tiers with multipliers for XP
export const RARITY_TIERS = {
common: { label: 'Common', color: '#9ca3af', xpMultiplier: 1, bgGradient: 'from-gray-500/20 to-gray-600/20' },
uncommon: { label: 'Uncommon', color: '#22c55e', xpMultiplier: 1.5, bgGradient: 'from-green-500/20 to-emerald-600/20' },
rare: { label: 'Rare', color: '#3b82f6', xpMultiplier: 2, bgGradient: 'from-blue-500/20 to-indigo-600/20' },
epic: { label: 'Epic', color: '#a855f7', xpMultiplier: 3, bgGradient: 'from-purple-500/20 to-violet-600/20' },
legendary: { label: 'Legendary', color: '#fbbf24', xpMultiplier: 5, bgGradient: 'from-yellow-400/20 to-orange-500/20' },
} as const;
// Calculate player level based on total items and value
export function calculateSkaterLevel(stats: { total: number; totalValue: number; byCategory: Record<string, number> }): {
level: number;
currentXP: number;
xpToNextLevel: number;
title: string;
progress: number;
} {
// XP formula: 100 per item + value/100
const xp = stats.total * 100 + Math.floor(stats.totalValue / 100);
// Level formula: sqrt(xp/100)
const level = Math.max(1, Math.floor(Math.sqrt(xp / 100)));
const xpForCurrentLevel = level * level * 100;
const xpForNextLevel = (level + 1) * (level + 1) * 100;
const currentXP = xp - xpForCurrentLevel;
const xpToNextLevel = xpForNextLevel - xpForCurrentLevel;
const progress = Math.min(100, (currentXP / xpToNextLevel) * 100);
// Titles based on level
const titles: Record<number, string> = {
1: 'Grom',
2: 'Pusher',
3: 'Street Rat',
5: 'Park Local',
8: 'Session Regular',
12: 'Spot Hunter',
15: 'Gear Head',
20: 'Skate Collector',
25: 'Board Hoarder',
30: 'Stash Master',
50: 'Legendary Collector',
};
let title = 'Grom';
for (const [lvl, t] of Object.entries(titles)) {
if (level >= parseInt(lvl)) title = t;
}
return { level, currentXP, xpToNextLevel, title, progress };
}