SkateSpots/src/hooks/useStash.ts
shakespeare.diy 0ac5c1019d fix: eliminate all render-path nip44 getter crashes (Browser extension not available)
Root cause: NUser's extension signer exposes .nip44 as a getter that calls
.nostr (window.nostr) and THROWS when the extension isn't present. Any
synchronous access during render crashes the whole tree — the actual culprit
was useStash's `isEncryptionAvailable: !!user?.signer?.nip44` in its return
object, evaluated every render even when logged out.

- Add src/lib/signerCapabilities.ts: hasNip44/hasNip04 helpers that determine
  capability via login type + direct window.nostr probe (no throwing getter).
- useStash: use the helper for hasNip44 + isEncryptionAvailable
- useDirectMessages: replace all render-path `enabled: !!user?.signer?.nip44`
  with a pre-computed nip44Available across all 4 conversation hooks
- CrewChat, CrewRequestToJoin, ConversationPage, DirectMessagesPage,
  ContactsPage: use the safe helper in render/guard paths

Async accesses (inside queryFn/callbacks) are left as-is — they reject
gracefully within react-query and never crash render.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-30 19:37:08 +07:00

288 lines
9.8 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 } 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
*/
export function useStash() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const queryClient = useQueryClient();
const { mutateAsync: createEvent } = useNostrPublish();
// Determine if nip44 is safely available WITHOUT touching any throwing getter.
const hasNip44 = useMemo(() => checkNip44(user, logins), [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: !!user?.pubkey && hasNip44,
staleTime: 60000, // 1 minute
});
// 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)'],
],
});
// Invalidate cache
queryClient.invalidateQueries({ queryKey: ['stash', user.pubkey] });
}, [user, createEvent, queryClient]);
// Add item mutation
const addItem = useMutation({
mutationFn: async (item: Omit<StashItem, 'id' | 'addedAt'>) => {
const currentStash = stash || { items: [], updatedAt: 0 };
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 = stash || { items: [], updatedAt: 0 };
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 = stash || { items: [], updatedAt: 0 };
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: '⚙️', 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<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 };
}