/** * useStashShowcase * ───────────────────────────────────────────────────────────────────────────── * Public visibility for Stash items (the public Stash shown on a profile). * * The private Stash (kind 30001) is a single NIP-44 encrypted blob — nothing in * it is ever readable by others. To make a Stash item genuinely *public*, we * publish it as a SEPARATE, unencrypted NIP-99 classified listing (kind 30402), * exactly like the marketplace. This hook keeps those public events in sync with * the encrypted Stash and lets any profile query another user's public Stash. * * Hybrid model: * • Public + NOT for sale → showcase-only. Tagged `t: stash-showcase` with a * `["showcase", "true"]` marker and `["price", "0", "SATS"]`. Shows in the * profile's public Stash, but is filtered OUT of the sellable marketplace. * • Public + willingToSell/Trade → full listing. Shows in the profile's public * Stash (badged "For Sale") AND flows into the marketplace like any listing. * * Each public event's `d` tag is the Stash item's stable `id`, so re-publishing * updates in place and we can delete (NIP-09) when an item goes private/removed. */ import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; import type { StashItem } from './useStash'; import type { NostrEvent } from '@nostrify/nostrify'; const LISTING_KIND = 30402; // NIP-99 Classified Listing const SHOWCASE_TAG = 'stash-showcase'; const WANT_TAG = 'stash-want'; const D_PREFIX = 'stash-'; // public event d-tag = `stash-` /** A public gear item as seen on someone's profile (their public Stash). */ export interface PublicStashItem { id: string; identifier: string; // d-tag pubkey: string; title: string; category: string; condition?: string; brand?: string; size?: string; description?: string; images: string[]; /** true when the owner marked it for sale/trade (has a real price or trade flag) */ forSale: boolean; willingToTrade: boolean; priceSats?: number; createdAt: number; event: NostrEvent; } function tag(event: NostrEvent, name: string): string | undefined { return event.tags.find((t) => t[0] === name)?.[1]; } /** Parse a kind-30402 event that originated from a Stash item. */ export function parsePublicStashItem(event: NostrEvent): PublicStashItem | null { if (event.kind !== LISTING_KIND) return null; // Must be one of our showcase events. const isShowcase = event.tags.some((t) => t[0] === 't' && t[1] === SHOWCASE_TAG); if (!isShowcase) return null; const identifier = tag(event, 'd'); const title = tag(event, 'title'); if (!identifier || !title) return null; const showcaseOnly = tag(event, 'showcase') === 'true'; const willingToTrade = tag(event, 'trade') === 'true'; const priceTag = event.tags.find((t) => t[0] === 'price'); const priceSats = priceTag ? parseInt(priceTag[1] || '0', 10) : 0; const images = event.tags.filter((t) => t[0] === 'image').map((t) => t[1]).filter(Boolean); const categoryTags = event.tags.filter((t) => t[0] === 't' && t[1] !== SHOWCASE_TAG && t[1] !== 'skateboarding'); const category = tag(event, 'category') || categoryTags[0]?.[1] || 'other'; return { id: event.id, identifier, pubkey: event.pubkey, title, category, condition: tag(event, 'condition'), brand: tag(event, 'brand'), size: tag(event, 'size'), description: event.content || undefined, images, forSale: !showcaseOnly, willingToTrade, priceSats: priceSats > 0 ? priceSats : undefined, createdAt: event.created_at, event, }; } /** Build the tag set for a public showcase/listing/want event from a Stash item. */ function buildListingTags(item: StashItem): string[][] { const isWtb = item.itemType === 'wtb'; const forSale = !!(item.willingToSell || item.willingToTrade); // A want advertises the most they'd PAY (maxPrice); a sale the least they'd TAKE. const priceSats = isWtb ? (item.maxPrice && item.maxPrice > 0 ? item.maxPrice : 0) : (item.minPrice && item.minPrice > 0 ? item.minPrice : 0); const tags: string[][] = [ ['d', `${D_PREFIX}${item.id}`], ['title', item.title], ['t', 'skateboarding'], ['t', isWtb ? WANT_TAG : SHOWCASE_TAG], ['t', item.category], ['category', item.category], ['published_at', String(item.addedAt || Math.floor(Date.now() / 1000))], ['alt', isWtb ? `${item.title} — gear wanted by a SkateSpots skater` : `${item.title} — public gear from a SkateSpots stash`], ]; if (item.brand) tags.push(['brand', item.brand]); if (item.size) tags.push(['size', item.size]); if (item.geohash) tags.push(['g', item.geohash]); for (const img of item.images || []) tags.push(['image', img]); // A "condition" is meaningless for something you're looking for. if (!isWtb && item.condition) tags.push(['condition', item.condition]); if (isWtb) { // Public WANT → a real NIP-99 `wtb` listing, so sellers find it in the // Marketplace (the stash Wishlist tab keeps the private copy). tags.push(['listing_type', 'wtb']); tags.push(['price', String(priceSats), 'SATS']); tags.push(['price_type', priceSats > 0 ? 'fixed' : item.willingToTrade ? 'trade' : 'offer']); tags.push(['status', 'active']); if (item.willingToTrade) tags.push(['trade', 'true']); return tags; } if (forSale) { // Real, sellable listing → appears in the marketplace. tags.push(['price', String(priceSats), 'SATS']); tags.push(['price_type', priceSats > 0 ? 'fixed' : (item.willingToTrade ? 'trade' : 'offer')]); tags.push(['listing_type', 'selling']); tags.push(['status', 'active']); if (item.willingToTrade) tags.push(['trade', 'true']); } else { // Showcase only → visible on profile, filtered out of the sellable market. tags.push(['price', '0', 'SATS']); tags.push(['showcase', 'true']); tags.push(['status', 'active']); } return tags; } /** * Actions to keep public events in sync with the encrypted Stash. * Call syncItem whenever an item is added/edited, and unpublishItem when it * goes private or is removed. */ export function useStashShowcaseSync() { const { user } = useCurrentUser(); const { mutateAsync: createEvent } = useNostrPublish(); /** Publish (or refresh) an item's public event when visibility === 'public'. */ const syncItem = useCallback( async (item: StashItem) => { if (!user?.pubkey) return; if (item.visibility !== 'public') return; // only public items get a public event // Public gear → profile showcase / marketplace listing. // Public WANT → a NIP-99 `wtb` listing so sellers find it in the Marketplace. await createEvent({ kind: LISTING_KIND, content: item.description || '', tags: buildListingTags(item), }); }, [user, createEvent], ); /** Remove an item's public event (NIP-09) when it goes private or is deleted. */ const unpublishItem = useCallback( async (itemId: string) => { if (!user?.pubkey) return; const dTag = `${D_PREFIX}${itemId}`; await createEvent({ kind: 5, // NIP-09 deletion request content: 'Removed from public showcase', tags: [['a', `${LISTING_KIND}:${user.pubkey}:${dTag}`]], }); }, [user, createEvent], ); return { syncItem, unpublishItem }; } /** * Query a user's public Stash items for their profile. * Returns showcase + for-sale items, newest first. */ export function useUserPublicStash(pubkey: string | undefined) { const { nostr } = useNostr(); return useQuery({ queryKey: ['public-stash', pubkey], enabled: !!pubkey, staleTime: 60_000, queryFn: async ({ signal }): Promise => { if (!pubkey) return []; const timeout = AbortSignal.any([signal, AbortSignal.timeout(6000)]); const events = await nostr.query( [{ kinds: [LISTING_KIND], authors: [pubkey], '#t': [SHOWCASE_TAG], limit: 100 }], { signal: timeout }, ); // Deduplicate addressable events by d-tag (keep newest), drop deleted/sold. const byD = new Map(); for (const ev of events) { const d = tag(ev, 'd'); if (!d) continue; const existing = byD.get(d); if (!existing || ev.created_at > existing.created_at) byD.set(d, ev); } const items: PublicStashItem[] = []; for (const ev of byD.values()) { const status = tag(ev, 'status'); if (status === 'sold') continue; const parsed = parsePublicStashItem(ev); if (parsed) items.push(parsed); } return items.sort((a, b) => b.createdAt - a.createdAt); }, }); }