- #3 usePersonalContext decrypts the Stash ON DEMAND (unlockStash, called by getMyStash) instead of never — 'what's in my stash?' is now correct, while the globally-mounted guide still never decrypts on mount. stashRef avoids the stale-closure bug. - #4 new self-contained action draft wtb_gear (NIP-99 listing_type=wtb) — a 'wanted' gear ad. SYSTEM_PROMPT + tool handler + publish branch. - #5 panel header shows 'Pro · Nd' days left (title = exact expiry). - #6 tier picker computes live USD via useExchangeRate; removed the hardcoded ~$60k/BTC usd field from PRO_TIERS. - #7 copy: 'skate status' not 'spot status'; trip planning stated as free.
194 lines
7.3 KiB
TypeScript
194 lines
7.3 KiB
TypeScript
/**
|
|
* 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<T extends { pubkey: string; kind: number; tags: string[][]; created_at: number }>(
|
|
events: T[],
|
|
): T[] {
|
|
const seen = new Map<string, T>();
|
|
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<Array<() => 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<void>((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<string, number> = {};
|
|
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,
|
|
};
|
|
}
|