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 { toast } from './useToast'; 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 { 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'; /** * Last local modification time (epoch seconds). Used to resolve multi-device * merge conflicts — the newest edit of an item wins. Legacy items don't have * it; `addedAt` is the fallback (see `itemStamp`). */ updatedAt?: number; } export interface Stash { items: StashItem[]; updatedAt: number; /** * Tombstones for deleted items. The stash is one replaceable blob, so a plain * "remove from the array" can be *resurrected* by another device's stale copy * during a merge. Recording when an item was deleted lets deletes win instead. * Optional — older stashes simply have none. */ deleted?: Array<{ id: string; at: number }>; } // Kind 30001 is NIP-51 "Follow sets" - we use a private encrypted list const STASH_KIND = 30001; const STASH_D_TAG = 'skatespots-stash'; /** Epoch seconds an item was last touched (legacy items fall back to `addedAt`). */ function itemStamp(i: StashItem): number { return i.updatedAt ?? i.addedAt ?? 0; } /** Tombstones older than this are dropped — a device offline longer than this * could resurrect a very old delete, which is an acceptable trade. */ const TOMBSTONE_MAX_AGE = 180 * 24 * 60 * 60; const TOMBSTONE_CAP = 500; /** * Merge two stash snapshots WITHOUT losing data. * * Why this exists: the stash is a single replaceable event whose content is the * WHOLE list, so naive last-write-wins silently drops whatever another device * added. This is the multi-device conflict guard. It is deliberately a *blob* * merge, not per-item events: per-item events would mean N decrypts per load, * and with Amber every decrypt is a popup. * * Rules: * • union by item id — an item only one side knows about is kept; * • on a collision the newest `updatedAt` (fallback `addedAt`) wins; * • a tombstone (delete) wins when it is at/after the item's last edit; * • tombstones are unioned (newest per id) and pruned. * * `merged` is true when the remote side contributed something (a new item, a * newer edit, or a delete) — the caller can tell the user. For one device this * is always a no-op with `merged === false`. */ export function mergeStashes(local: Stash, remote: Stash): { stash: Stash; merged: boolean } { const localItems = new Map(local.items.map((i) => [i.id, i])); const remoteItems = new Map(remote.items.map((i) => [i.id, i])); const collectTombs = (s: Stash) => { const m = new Map(); for (const t of s.deleted ?? []) { if (!t?.id) continue; m.set(t.id, Math.max(m.get(t.id) ?? 0, t.at ?? 0)); } return m; }; const localTombs = collectTombs(local); const remoteTombs = collectTombs(remote); const tombs = new Map(localTombs); for (const [id, at] of remoteTombs) tombs.set(id, Math.max(tombs.get(id) ?? 0, at)); const ids = new Set([...localItems.keys(), ...remoteItems.keys()]); const items: StashItem[] = []; let merged = false; for (const id of ids) { const l = localItems.get(id); const r = remoteItems.get(id); let winner: StashItem; if (!l) { winner = r!; merged = true; // item only the remote knew about } else if (!r) { winner = l; } else if (itemStamp(r) > itemStamp(l)) { winner = r; merged = true; // the other device edited this item more recently } else { winner = l; } const tomb = tombs.get(id); if (tomb !== undefined && tomb >= itemStamp(winner)) { // Deleted at/after the last edit — keep it deleted. if (l && remoteTombs.has(id) && !localTombs.has(id)) merged = true; continue; } items.push(winner); } const now = Math.floor(Date.now() / 1000); const deleted = [...tombs.entries()] .filter(([, at]) => at > now - TOMBSTONE_MAX_AGE) .sort((a, b) => b[1] - a[1]) .slice(0, TOMBSTONE_CAP) .map(([id, at]) => ({ id, at })); return { stash: { items, deleted, updatedAt: now }, merged }; } /** * 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 => { if (!user?.pubkey || !hasNip44) { return { items: [], updatedAt: 0 }; } const pubkey = user.pubkey; const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]); // 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 }; // 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 }; // Keep the ciphertext for next time. cacheStashEvent(pubkey, event).catch(() => {}); try { // 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 }; } }, enabled: callerEnabled && !!user?.pubkey && hasNip44, staleTime: interactive ? Infinity : 60000, // Keep the decrypted stash in memory for the session so navigating away and // back doesn't re-open Amber (gcTime would otherwise evict it after 5 min). gcTime: interactive ? Infinity : undefined, refetchOnMount: !interactive, refetchOnWindowFocus: false, refetchOnReconnect: !interactive, }); /** * 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: Stash; confirmed: boolean }> => { const fallback: Stash = queryClient.getQueryData(['stash', user?.pubkey ?? '']) ?? stash ?? { items: [], updatedAt: 0 }; if (!user?.pubkey || !hasNip44) return { stash: fallback, confirmed: false }; let nip44Dec: typeof user.signer.nip44; try { nip44Dec = user.signer?.nip44; } catch { nip44Dec = undefined; } if (!nip44Dec) { // Can't decrypt now — return what we have, flagged unconfirmed so the // caller refuses to write rather than risk a clobber. return { stash: fallback, confirmed: false }; } // `confirmed` means a relay actually answered this read. It's the signal the // callers use to decide whether a write is safe (see assertConfirmed). let confirmed = false; 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]; confirmed = true; // the relay answered (possibly with "nothing") } catch { // unreachable — fall through to the cached ciphertext } if (!event) event = await getCachedStashEvent(user.pubkey); if (!event) { // No stash anywhere: safe to start fresh, but only "confirmed" if the // relay actually answered. return { stash: fallback, confirmed }; } cacheStashEvent(user.pubkey, event).catch(() => {}); const remote = JSON.parse(await nip44Dec.decrypt(user.pubkey, event.content)) as Stash; // Multi-device conflict guard. The list we already hold (React Query cache, // then the hook's closure) can contain items the relay doesn't have yet — // most commonly edits that were made a moment ago. Merge rather than let the // remote silently overwrite them; deletes still win via tombstones. On a // single device this is always a no-op. const known = queryClient.getQueryData(['stash', user.pubkey]) ?? stash; if (known) { const { stash: mergedStash, merged } = mergeStashes(known, remote); if (merged) { toast({ title: 'Merged changes from another device', description: 'Your stash was edited elsewhere — both sets of changes were kept.', }); } return { stash: mergedStash, confirmed }; } return { stash: remote, confirmed }; }, [user, hasNip44, nostr, queryClient, stash]); /** * Stash writes rewrite the WHOLE blob, so they must run against a just-read * relay copy for the merge above to protect other devices' changes. If the * relay couldn't be reached we refuse the write instead of letting * `useNostrPublish` queue it: a queued blob is published later as-is, and * could silently overwrite an edit another device made meanwhile. (Reads still * work offline — only writes need a connection.) */ const assertConfirmed = (confirmed: boolean) => { if (!confirmed) { throw new Error("Couldn't reach the relays. Reconnect and try again — saving now could overwrite a newer change from another device."); } }; // 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) => { const { stash: currentStash, confirmed } = await fetchLatestStash(); assertConfirmed(confirmed); const now = Math.floor(Date.now() / 1000); const newItem: StashItem = { ...item, id: crypto.randomUUID(), addedAt: now, updatedAt: now, }; const newStash: Stash = { items: [...currentStash.items, newItem], deleted: currentStash.deleted, updatedAt: now, }; await saveStash(newStash); return newItem; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); }, }); // Update item mutation const updateItem = useMutation({ mutationFn: async (item: StashItem) => { const { stash: currentStash, confirmed } = await fetchLatestStash(); assertConfirmed(confirmed); const now = Math.floor(Date.now() / 1000); const stamped: StashItem = { ...item, updatedAt: now }; const newStash: Stash = { items: currentStash.items.map(i => i.id === item.id ? stamped : i), deleted: currentStash.deleted, updatedAt: now, }; await saveStash(newStash); return stamped; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); }, }); // Remove item mutation const removeItem = useMutation({ mutationFn: async (itemId: string) => { const { stash: currentStash, confirmed } = await fetchLatestStash(); assertConfirmed(confirmed); const now = Math.floor(Date.now() / 1000); const newStash: Stash = { items: currentStash.items.filter(i => i.id !== itemId), // Record the delete so a merge with another device's stale copy can't // resurrect it. deleted: [...(currentStash.deleted ?? []), { id: itemId, at: now }], updatedAt: now, }; await saveStash(newStash); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); }, }); // 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 { stash: currentStash, confirmed } = await fetchLatestStash(); assertConfirmed(confirmed); 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, updatedAt: now }); } if (fresh.length === 0) return { added: 0, skipped: items.length }; await saveStash({ items: [...currentStash.items, ...fresh], deleted: currentStash.deleted, updatedAt: now }); return { added: fresh.length, skipped: items.length - fresh.length }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); }, }); // Bulk actions — apply to many items in ONE re-encrypt + publish. const bulkSetVisibility = useMutation({ mutationFn: async ({ ids, visibility }: { ids: string[]; visibility: StashItem['visibility'] }) => { const { stash: current, confirmed } = await fetchLatestStash(); assertConfirmed(confirmed); const now = Math.floor(Date.now() / 1000); const changed: StashItem[] = []; const next = current.items.map((i) => { if (!ids.includes(i.id)) return i; const updated = { ...i, visibility, updatedAt: now }; changed.push(updated); return updated; }); await saveStash({ items: next, deleted: current.deleted, updatedAt: now }); return { changed }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); }, }); const bulkSetForSale = useMutation({ mutationFn: async ({ ids, forSale }: { ids: string[]; forSale: boolean }) => { const { stash: current, confirmed } = await fetchLatestStash(); assertConfirmed(confirmed); const now = Math.floor(Date.now() / 1000); const changed: StashItem[] = []; const next = current.items.map((i) => { if (!ids.includes(i.id)) return i; const updated = { ...i, willingToSell: forSale, updatedAt: now }; changed.push(updated); return updated; }); await saveStash({ items: next, deleted: current.deleted, updatedAt: now }); return { changed }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); }, }); const bulkRemove = useMutation({ mutationFn: async (ids: string[]) => { const { stash: current, confirmed } = await fetchLatestStash(); assertConfirmed(confirmed); const now = Math.floor(Date.now() / 1000); const removed = current.items.filter((i) => ids.includes(i.id)); await saveStash({ items: current.items.filter((i) => !ids.includes(i.id)), deleted: [...(current.deleted ?? []), ...removed.map((i) => ({ id: i.id, at: now }))], updatedAt: now, }); return { removed }; }, 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 = {}; 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, importItems, bulkSetVisibility, bulkSetForSale, bulkRemove, 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: '⚙️', 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: '🛡️', 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: '👍 Good', color: '#ffcc00', stars: 3 }, well_used: { label: '⚠️ 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 }): { 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 = { 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 }; }