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>
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
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 };
|
||
}
|