Use the established "Stash" term everywhere instead of introducing a new word. The profile section is now titled "Stash", visibility copy reads "Shown in your public Stash on your profile", and internal identifiers were renamed (useUserQuiver → useUserPublicStash, QuiverItem → PublicStashItem, parseQuiverItem → parsePublicStashItem). NIP.md updated to match. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
218 lines
7.9 KiB
TypeScript
218 lines
7.9 KiB
TypeScript
/**
|
|
* 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 D_PREFIX = 'stash-'; // public event d-tag = `stash-<itemId>`
|
|
|
|
/** 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 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 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<PublicStashItem[]> => {
|
|
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<string, NostrEvent>();
|
|
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);
|
|
},
|
|
});
|
|
}
|