From 32a3cbca88220f45c6dde5e3412eb6b36e6185a0 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 2 Sep 2026 19:33:34 +0700 Subject: [PATCH] Public Stash items: "The Quiver" showcase + marketplace hybrid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the "Public" visibility on Stash items did nothing — the whole Stash is a single NIP-44 encrypted blob, so nobody could ever see a "public" item. This makes Public actually mean public. Model (hybrid A + marketplace): - Public items are now ALSO published as separate unencrypted NIP-99 kind 30402 events tagged `t: stash-showcase`, keyed by `stash-`. - Public + not-for-sale → showcase-only (`showcase: true`, price 0): shows on the profile "Quiver", filtered OUT of the sellable market. - Public + for sale/trade → full listing: shows on the profile Quiver (badged For Sale/Trade) AND flows into the marketplace. - Going private or deleting an item publishes a NIP-09 deletion for its public event, keeping everything consistent. Changes: - New hook useStashShowcase (sync/unpublish + useUserQuiver query). - StashPage wires add/edit/remove through sync handlers; honest visibility copy in add + edit dialogs. - Profile page (NIP19Page) renders a new "The Quiver" grid with For Sale/Trade badges + prices, and a Gear count in Contributions. - Marketplace hooks skip showcase-only events so they don't clutter buy/sell listings. - NIP.md documents the showcase event schema. Co-authored-by: shakespeare.diy --- NIP.md | 50 +++++++- src/hooks/useMarketplace.ts | 6 + src/hooks/useStashShowcase.ts | 218 ++++++++++++++++++++++++++++++++++ src/pages/NIP19Page.tsx | 94 ++++++++++++++- src/pages/StashPage.tsx | 71 +++++++++-- 5 files changed, 420 insertions(+), 19 deletions(-) create mode 100644 src/hooks/useStashShowcase.ts diff --git a/NIP.md b/NIP.md index e1cca74..2a99d11 100644 --- a/NIP.md +++ b/NIP.md @@ -1166,9 +1166,53 @@ When decrypted, the content contains a JSON object with the user's inventory: | Value | Description | |-------|-------------| -| `private` | Only visible to the owner. Shared manually via DM. | -| `friends` | Visible to mutual follows. Enables P2P matching. | -| `public` | Visible to anyone who views the user's profile. | +| `private` | Only visible to the owner. Encrypted in the kind 30001 blob. | +| `friends` | Reserved for future friends-only sharing (currently treated as private). | +| `public` | Published as a separate public event (see below) and shown in the owner's profile "Quiver". | + +### Public Showcase ("The Quiver") + +Because the Stash (kind 30001) is a single NIP-44 encrypted blob, its contents +are never readable by others. To make an item genuinely public, the client +**also** publishes it as a separate, unencrypted **NIP-99 Classified Listing +(kind 30402)** — the same kind used by the marketplace — with a +`["t", "stash-showcase"]` tag. The public event's `d` tag is `stash-`, +mirroring the private item's stable id so it can be updated in place or removed +via a NIP-09 deletion when the item goes private or is deleted. + +Two flavors (hybrid model): + +| Case | Marker tags | Where it appears | +|------|-------------|------------------| +| Public, **not** for sale | `["showcase", "true"]`, `["price", "0", "SATS"]` | Profile Quiver only (filtered out of the sellable marketplace) | +| Public **and** for sale/trade | `["price", "", "SATS"]`, `["listing_type", "selling"]`, `["status", "active"]`, optional `["trade", "true"]` | Profile Quiver (badged "For Sale") **and** the marketplace | + +Clients rendering the marketplace MUST skip events carrying +`["showcase", "true"]` so showcase-only items don't pollute buy/sell listings. + +Example public showcase event (not for sale): + +```json +{ + "kind": 30402, + "content": "My daily driver deck.", + "tags": [ + ["d", "stash-a1b2c3d4"], + ["title", "Baker Deck 8.25"], + ["t", "skateboarding"], + ["t", "stash-showcase"], + ["t", "deck"], + ["category", "deck"], + ["condition", "good"], + ["brand", "Baker"], + ["image", "https://blossom.example/deck.jpg"], + ["price", "0", "SATS"], + ["showcase", "true"], + ["status", "active"], + ["alt", "Baker Deck 8.25 — public gear from a SkateSpots stash"] + ] +} +``` ### Categories diff --git a/src/hooks/useMarketplace.ts b/src/hooks/useMarketplace.ts index bc1aaa4..b4f4482 100644 --- a/src/hooks/useMarketplace.ts +++ b/src/hooks/useMarketplace.ts @@ -30,6 +30,10 @@ export function useMarketplaceItems(limit = 100) { const items: MarketplaceItem[] = []; for (const event of events) { + // Skip showcase-only Stash items (public gear that is NOT for sale): + // they belong on the profile Quiver, not the sellable marketplace. + const isShowcaseOnly = event.tags.some((t) => t[0] === 'showcase' && t[1] === 'true'); + if (isShowcaseOnly) continue; const item = parseMarketplaceItem(event); if (item) items.push(item); } @@ -68,6 +72,8 @@ export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string, const items: MarketplaceItem[] = []; for (const event of events) { + const isShowcaseOnly = event.tags.some((t) => t[0] === 'showcase' && t[1] === 'true'); + if (isShowcaseOnly) continue; const item = parseMarketplaceItem(event); if (item && (!item.status || item.status === 'active')) { items.push(item); diff --git a/src/hooks/useStashShowcase.ts b/src/hooks/useStashShowcase.ts new file mode 100644 index 0000000..b07edad --- /dev/null +++ b/src/hooks/useStashShowcase.ts @@ -0,0 +1,218 @@ +/** + * useStashShowcase + * ───────────────────────────────────────────────────────────────────────────── + * Public visibility for Stash items ("The Quiver"). + * + * 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 quiver. + * + * Hybrid model: + * • Public + NOT for sale → showcase-only. Tagged `t: stash-showcase` with a + * `["showcase", "true"]` marker and `["price", "0", "SATS"]`. Shows on the + * profile Quiver, but is filtered OUT of the sellable marketplace. + * • Public + willingToSell/Trade → full listing. Shows on the profile Quiver + * (badged "For Sale") AND flows into the marketplace like any other 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 D_PREFIX = 'stash-'; // public event d-tag = `stash-` + +/** A public gear item as seen on someone's profile. */ +export interface QuiverItem { + 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 parseQuiverItem(event: NostrEvent): QuiverItem | 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 event from a Stash item. */ +function buildListingTags(item: StashItem): string[][] { + const forSale = !!(item.willingToSell || item.willingToTrade); + const priceSats = item.minPrice && item.minPrice > 0 ? item.minPrice : 0; + + const tags: string[][] = [ + ['d', `${D_PREFIX}${item.id}`], + ['title', item.title], + ['t', 'skateboarding'], + ['t', SHOWCASE_TAG], + ['t', item.category], + ['category', item.category], + ['published_at', String(item.addedAt || Math.floor(Date.now() / 1000))], + ['alt', `${item.title} — public gear from a SkateSpots stash`], + ]; + + if (item.condition) tags.push(['condition', item.condition]); + 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]); + + 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 + if (item.itemType === 'wtb') return; // wishlist items are not showcased + 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 quiver (public Stash items) for their profile. + * Returns showcase + for-sale items, newest first. + */ +export function useUserQuiver(pubkey: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['stash-quiver', 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: QuiverItem[] = []; + for (const ev of byD.values()) { + const status = tag(ev, 'status'); + if (status === 'sold') continue; + const parsed = parseQuiverItem(ev); + if (parsed) items.push(parsed); + } + + return items.sort((a, b) => b.createdAt - a.createdAt); + }, + }); +} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index b26a3cf..d27aec8 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -13,6 +13,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUserSkateSpots } from '@/hooks/useSkateSpots'; import { useUserShops } from '@/hooks/useShops'; import { useUserCrews } from '@/hooks/useCrews'; +import { useUserQuiver } from '@/hooks/useStashShowcase'; import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { genUserName } from '@/lib/genUserName'; import NotFound from './NotFound'; @@ -31,6 +32,9 @@ import { Crown, Bot, Pencil, + Package, + Tag, + ArrowRightLeft, } from 'lucide-react'; import { useState } from 'react'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; @@ -41,6 +45,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) { const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey); const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey); const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey); + const { data: quiver, isLoading: quiverLoading } = useUserQuiver(pubkey); const [copied, setCopied] = useState(false); const metadata = author.data?.metadata; @@ -297,6 +302,83 @@ function ProfilePage({ pubkey }: { pubkey: string }) { )} + + {/* The Quiver — public gear from this skater's Stash */} + {(quiverLoading || (quiver && quiver.length > 0)) && ( +
+
+

+ + The Quiver + {quiver && quiver.length > 0 && ( + + {quiver.length} + + )} +

+ {isOwnProfile && ( + + Manage + + )} +
+ + {quiverLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {quiver!.map((g) => ( +
+
+ {g.images[0] ? ( + {g.title} + ) : ( + + )} + {g.forSale && ( + + {g.willingToTrade && !g.priceSats ? ( + <>Trade + ) : ( + <>For Sale + )} + + )} +
+
+

{g.title}

+
+ {g.brand ? ( +

{g.brand}

+ ) : ( +

{g.category}

+ )} + {g.forSale && g.priceSats ? ( + + + {g.priceSats > 999 ? `${Math.floor(g.priceSats / 1000)}k` : g.priceSats} + + ) : null} +
+
+
+ ))} +
+ )} +
+ )} {/* Sidebar */} @@ -400,18 +482,22 @@ function ProfilePage({ pubkey }: { pubkey: string }) {

Contributions

-
+

{spots?.length || 0}

-

Spots

+

Spots

{shops?.length || 0}

-

Shops

+

Shops

{crews.length}

-

Crews

+

Crews

+
+
+

{quiver?.length || 0}

+

Gear

diff --git a/src/pages/StashPage.tsx b/src/pages/StashPage.tsx index 9669022..78d96d8 100644 --- a/src/pages/StashPage.tsx +++ b/src/pages/StashPage.tsx @@ -15,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { LoginArea } from '@/components/auth/LoginArea'; import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash'; +import { useStashShowcaseSync } from '@/hooks/useStashShowcase'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useSatsToUsd } from '@/hooks/useExchangeRate'; @@ -419,8 +420,8 @@ function AddItemDialog({ onAdd, mode = 'stash', trigger }: { onAdd: (item: Omit<

{formData.visibility === 'private' && 'Encrypted — only visible to you.'} - {formData.visibility === 'friends' && 'Visible to people you mutually follow.'} - {formData.visibility === 'public' && 'Anyone can see this in your stash.'} + {formData.visibility === 'friends' && 'Kept private for now (friends-only sharing coming soon).'} + {formData.visibility === 'public' && 'Shown on your public profile in "The Quiver". If it\u2019s also for sale/trade, it appears in the marketplace too.'}

@@ -546,6 +547,11 @@ function EditItemDialog({ item, open, onOpenChange, onSave }: { item: StashItem;
Public
+

+ {formData.visibility === 'private' && 'Encrypted — only visible to you.'} + {formData.visibility === 'friends' && 'Kept private for now (friends-only sharing coming soon).'} + {formData.visibility === 'public' && 'Shown on your public profile in "The Quiver". If it\u2019s also for sale/trade, it appears in the marketplace too.'} +

@@ -601,6 +607,47 @@ export default function StashPage() { const navigate = useNavigate(); const { user } = useCurrentUser(); const { items, stats, isLoading, addItem, updateItem, removeItem, isEncryptionAvailable } = useStash(); + const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync(); + + // Add: save to encrypted stash, then publish a public event if it's public. + const handleAddItem = (item: Omit) => { + addItem.mutate(item, { + onSuccess: (created) => { + if (created.visibility === 'public' && created.itemType !== 'wtb') { + syncShowcase(created).catch(() => {}); + } + }, + }); + }; + + // Edit: update the encrypted stash, then reconcile the public event. + const handleUpdateItem = (updated: StashItem) => { + const previous = items.find((i) => i.id === updated.id); + updateItem.mutate(updated, { + onSuccess: () => { + const wasPublic = previous?.visibility === 'public'; + const isPublic = updated.visibility === 'public' && updated.itemType !== 'wtb'; + if (isPublic) { + syncShowcase(updated).catch(() => {}); + } else if (wasPublic) { + // No longer public → pull the public event down. + unpublishShowcase(updated.id).catch(() => {}); + } + }, + }); + }; + + // Remove: delete from stash, and clean up any public event. + const handleRemoveItem = (itemId: string) => { + const target = items.find((i) => i.id === itemId); + removeItem.mutate(itemId, { + onSuccess: () => { + if (target?.visibility === 'public') { + unpublishShowcase(itemId).catch(() => {}); + } + }, + }); + }; const [searchQuery, setSearchQuery] = useState(''); const [filterCategory, setFilterCategory] = useState('all'); const [filterLocation, setFilterLocation] = useState('all'); @@ -740,7 +787,7 @@ export default function StashPage() { )} {/* Mobile-only quick add — desktop has sidebar buttons */}
- addItem.mutate(item)} mode="stash" /> +
@@ -839,7 +886,7 @@ export default function StashPage() {

Wishlist Empty

Add gear you're hunting for — we'll match you with friends' stashes.

- addItem.mutate(item)} mode="wtb" /> + ) : stashItems.length === 0 ? ( <> @@ -851,7 +898,7 @@ export default function StashPage() {

Your Stash is Empty

Start tracking your gear. Decks, trucks, shoes — add it once, access it everywhere via Nostr.

- addItem.mutate(item)} /> + ) : ( <> @@ -868,14 +915,14 @@ export default function StashPage() {
{filteredItems.map((item) => ( setEditingItem(item)} onDelete={() => removeItem.mutate(item.id)} onListOnMarketplace={() => setListingItem(item)} /> + onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} /> ))}
) : (
{filteredItems.map((item) => ( setEditingItem(item)} onDelete={() => removeItem.mutate(item.id)} onListOnMarketplace={() => setListingItem(item)} /> + onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} /> ))}
)} @@ -886,8 +933,8 @@ export default function StashPage() { {/* Quick actions */}
- addItem.mutate(item)} mode="stash" /> - addItem.mutate(item)} mode="wtb" /> + +
{/* Stats card */} @@ -1063,17 +1110,17 @@ export default function StashPage() { {/* Mobile FABs */}
- addItem.mutate(item)} mode="wtb" + } /> - addItem.mutate(item)} mode="stash" + } />
{editingItem && ( !open && setEditingItem(null)} - onSave={(updatedItem) => { updateItem.mutate(updatedItem); setEditingItem(null); }} /> + onSave={(updatedItem) => { handleUpdateItem(updatedItem); setEditingItem(null); }} /> )} {listingItem && ( !open && setListingItem(null)} onSelectSpot={handleSpotSelection} />