/** * usePersonalContext — Sasquatch Pro "personal assistant" data layer. * ───────────────────────────────────────────────────────────────────────────── * Read-only access to the LOGGED-IN user's own data so Sasquatch can answer * questions like "what's in my stash?" or "events near my crew?". * * PRIVACY: everything here reads the user's OWN data client-side. The encrypted * Stash is decrypted locally by the signer (never sent to any AI/server). These * getters only run when a user is logged in; otherwise they return empty/guards. * * Mirrors the useCVMClient shape (async getters returning plain JSON) so the * Sasquatch tool switch can call them the same way as the public tools. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useStash, type StashItem } from '@/hooks/useStash'; import { MARKETPLACE_ITEM_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, parseSkateCrew, type SkateCrew, } from '@/lib/types'; const NOT_LOGGED_IN = { error: 'not_logged_in', message: 'Log in to use personal features.' } as const; function getTag(tags: string[][], name: string): string | undefined { return tags.find((t) => t[0] === name)?.[1]; } function getTags(tags: string[][], name: string): string[] { return tags.filter((t) => t[0] === name).map((t) => t[1]).filter(Boolean); } /** Latest-wins dedupe for addressable events by pubkey+kind+d. */ function dedupeAddressable( events: T[], ): T[] { const seen = new Map(); for (const e of events) { const key = `${e.pubkey}:${e.kind}:${getTag(e.tags, 'd') ?? ''}`; const cur = seen.get(key); if (!cur || e.created_at > cur.created_at) seen.set(key, e); } return [...seen.values()]; } export function usePersonalContext() { const { nostr } = useNostr(); const { user } = useCurrentUser(); // The encrypted Stash is decrypted ONLY when the AI actually reads it (see // unlockStash) — never on mount. Sasquatch is mounted in the GLOBAL Layout, so // eager decryption fired an Amber popup per navigation. This keeps that away, // while making "what's in my stash?" correct when the user actually asks. const [stashWanted, setStashWanted] = useState(false); const { stash } = useStash({ enabled: stashWanted }); const stashRef = useRef(stash); stashRef.current = stash; const stashWaiters = useRef void>>([]); useEffect(() => { if (stashWanted && stash !== undefined) { const waiters = stashWaiters.current.splice(0); waiters.forEach((resolve) => resolve()); } }, [stashWanted, stash]); /** Decrypt the stash on demand — a genuine, user-initiated action. */ const unlockStash = useCallback(async () => { if (stashRef.current !== undefined) return; setStashWanted(true); await new Promise((resolve) => stashWaiters.current.push(resolve)); }, []); const pubkey = user?.pubkey; /** Whether personal tools can run at all. */ const available = !!pubkey; // ── My Stash (decrypted on demand by unlockStash) ───────────────────────── const getMyStash = useCallback(async () => { if (!pubkey) return NOT_LOGGED_IN; await unlockStash(); const items = stashRef.current?.items ?? []; const byCategory: Record = {}; for (const it of items) byCategory[it.category] = (byCategory[it.category] ?? 0) + 1; const summary = (it: StashItem) => ({ title: it.title, category: it.category, brand: it.brand, size: it.size, condition: it.condition, willingToSell: it.willingToSell, willingToTrade: it.willingToTrade, minPrice: it.minPrice, storageLocation: it.storageLocation, visibility: it.visibility, itemType: it.itemType ?? 'stash', }); return { count: items.length, by_category: byCategory, for_sale: items.filter((i) => i.willingToSell).map(summary), items: items.map(summary), }; }, [pubkey, unlockStash]); // ── My Crews (founder or confirmed member) ──────────────────────────────── const getMyCrews = useCallback(async () => { if (!pubkey) return NOT_LOGGED_IN; const signal = AbortSignal.timeout(8000); const raw = await nostr.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }); const crews = dedupeAddressable(raw) .map((e) => parseSkateCrew(e)) .filter((c): c is SkateCrew => !!c) .filter((c) => c.pubkey === pubkey || c.members.includes(pubkey)); return { count: crews.length, crews: crews.map((c) => ({ name: c.name, role: c.pubkey === pubkey ? 'founder' : c.admins.includes(pubkey) ? 'admin' : 'member', location: c.location, geohash: c.geohash, members: c.members.length, vibes: c.vibes, band: c.bandName, url: `https://skate-spots.org/crew/${c.pubkey}/${c.identifier}`, })), }; }, [pubkey, nostr]); // ── My marketplace listings (gear I'm selling) ──────────────────────────── const getMyListings = useCallback(async () => { if (!pubkey) return NOT_LOGGED_IN; const signal = AbortSignal.timeout(8000); const raw = await nostr.query( [{ kinds: [MARKETPLACE_ITEM_KIND], authors: [pubkey], limit: 100 }], { signal }, ); const listings = dedupeAddressable(raw) // Skip tombstoned/sold-out listings that set status=sold if present. .filter((e) => getTag(e.tags, 'status') !== 'sold'); return { count: listings.length, listings: listings.map((e) => ({ title: getTag(e.tags, 'title') ?? 'Untitled', price: getTag(e.tags, 'price'), currency: getTag(e.tags, 'currency'), condition: getTag(e.tags, 'condition'), categories: getTags(e.tags, 't'), url: `https://skate-spots.org/marketplace`, })), }; }, [pubkey, nostr]); // ── My spots (spots I created) ──────────────────────────────────────────── const getMySpots = useCallback(async () => { if (!pubkey) return NOT_LOGGED_IN; const signal = AbortSignal.timeout(8000); const raw = await nostr.query( [{ kinds: [SKATE_SPOT_KIND], authors: [pubkey], limit: 200 }], { signal }, ); const spots = dedupeAddressable(raw).filter((e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot'), ); return { count: spots.length, spots: spots.map((e) => ({ name: getTag(e.tags, 'title') ?? getTag(e.tags, 'name') ?? 'Unnamed Spot', city: getTag(e.tags, 'city'), country: getTag(e.tags, 'country'), difficulty: getTag(e.tags, 'difficulty'), url: `https://skate-spots.org/spot/${e.pubkey}/${getTag(e.tags, 'd') ?? ''}`, })), }; }, [pubkey, nostr]); return { available, unlockStash, getMyStash, getMyCrews, getMyListings, getMySpots, }; }