diff --git a/AGENTS.md b/AGENTS.md index 292e9c4..ecf6e0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,8 +80,16 @@ session with **no other memory of this project**. So: manages incoming requests, pending confirmations, and the user's trade records, and can **revoke** a record (`s=revoked`, excluded from reputation) for a dispute or mistake. Structured **offers** (kind `30403`, `useMarketplaceOffers` + the - `ListingOffersPanel`/`OfferDialog`) let a buyer propose an amount and the seller - accept/decline. + `ListingOffersPanel`/`OfferDialog`) are a **counter-offer negotiation thread + (2026-09-29):** every message is its own event with a **unique `d`** (append-only) + carrying `role` (`offer` = buyer / `response` = seller), `e` = the message it + answers, and `status`; either side can **accept / decline / withdraw / counter** + and `ListingOffersPanel` shows the volley + whose turn it is. Legacy fixed-slot + `d`s are still parsed (role inferred when the `role` tag is absent) — see NIP.md + → "Marketplace Offers". Trade/offer steps also feed **`useTradeNotifications`** + → the bell's **All** tab (`TradeNotificationCard`, counted in the badge against + `seenAt`) and OS notifications (new **`trades`** category in `notificationPrefs`, + wired in `OsNotificationBridge`). - **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a diff --git a/NIP.md b/NIP.md index 2e0dc2e..b1f2dca 100644 --- a/NIP.md +++ b/NIP.md @@ -779,24 +779,36 @@ For trust and spam prevention, sellers should have a verified NIP-05 identifier. ## Marketplace Offers (kind 30403) -A buyer's structured proposal for a listing, which the seller can accept or -decline. SkateSpots-native (no existing NIP defines offers). Addressable, so an -offer/response can be updated in place. +A structured **negotiation** over a listing. Either side can offer, counter, +accept, decline, or withdraw — a back-and-forth thread, not a single request. -An **offer** is authored by the buyer; a **response** by the seller: +**Every message is its own event with a UNIQUE `d`**, so counters never +overwrite the message they answer (the thread is append-only). The current state +of a negotiation is simply its newest message. -| Field | Offer (`d = :offer:`) | Response (`d = :offer-response:`) | -| --- | --- | --- | -| `a` | the listing coord (`30402::`) | the listing coord | -| `p` | the **seller** | the **buyer** | -| `e` | — | the **offer** event id being answered | -| `status` | `open` \| `withdrawn` | `accepted` \| `declined` | -| `amount` | optional `["amount","45000","SATS"]` | mirrored | -| `alt` | `SkateSpots: marketplace offer` | same | +| Field | Value | +| --- | --- | +| `d` | `:::` — **unique**, one per message | +| `a` | the listing coord (`30402::`) | +| `p` | the **other party** (offer → seller; response → buyer) | +| `e` | the message this one answers (a counter / accept / decline) — absent only on the first offer | +| `role` | `offer` (authored by the buyer) \| `response` (authored by the seller) | +| `status` | `open` \| `accepted` \| `declined` \| `withdrawn` | +| `amount` | optional `["amount","45000","SATS"]` | +| `alt` | `SkateSpots: marketplace offer` | -`content` is an optional human message. Clients show a seller their incoming -offers (open, `#p` = them) and a buyer their own offer plus the seller's -response. Payment is still arranged directly between the two skaters. +`content` is an optional human message. Either party may answer the newest `open` +message with a **counter** (`status=open`, new `amount`, `e` = the message being +answered), or **accept/decline** it. Negotiations are grouped by +`(listing, buyer)` into threads; a thread is "awaiting" whoever did not author +its newest `open` message. + +> ⚠️ Legacy events used fixed slots (`d = :offer:` and +> `:offer-response:`, where a re-publish replaced the message). +> Clients MUST still parse those (`role` inferred from the `d` marker when the +> `role` tag is absent), but new messages use the unique-`d` thread form. + +Payment is still arranged directly between the two skaters. ## Community Contributions diff --git a/src/components/ListingOffersPanel.tsx b/src/components/ListingOffersPanel.tsx index c5f50ad..239c708 100644 --- a/src/components/ListingOffersPanel.tsx +++ b/src/components/ListingOffersPanel.tsx @@ -4,14 +4,20 @@ import { nip19 } from 'nostr-tools'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; -import { useOfferActions, useListingOffers, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers'; +import { + useOfferActions, + useListingOffers, + type MarketplaceOffer, + type OfferThread, +} from '@/hooks/useMarketplaceOffers'; import { useSendDirectMessage } from '@/hooks/useDirectMessages'; import { genUserName } from '@/lib/genUserName'; import type { MarketplaceItem } from '@/lib/types'; -import { Handshake, CheckCircle, X, Loader2 } from 'lucide-react'; +import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2 } from 'lucide-react'; function PeerName({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); @@ -36,40 +42,49 @@ function statusBadge(status: MarketplaceOffer['status']) { } } +function amountLabel(m: MarketplaceOffer): string { + return m.amount ? `${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}` : 'no amount'; +} + /** - * Offers for a listing: the seller sees incoming offers with accept/decline; - * a buyer sees their own offer and the seller's response. + * Offers for a listing, as a **negotiation thread**. Either side can accept, + * decline, withdraw, or **counter** with a new amount — and it's always clear + * whose turn it is. */ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) { const { user } = useCurrentUser(); const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier }); - const { respondToOffer } = useOfferActions(); + const { respondToOffer, counterOffer } = useOfferActions(); const { mutateAsync: sendDM } = useSendDirectMessage(); const { toast } = useToast(); const [busy, setBusy] = useState(null); + const [counterFor, setCounterFor] = useState(null); + const [counterAmount, setCounterAmount] = useState(''); if (!user || !data) return null; - const respond = async (offer: MarketplaceOffer, status: 'accepted' | 'declined') => { - setBusy(offer.id); + const mine = data.threads.filter((t) => t.buyer === user.pubkey || t.seller === user.pubkey); + if (mine.length === 0) return null; + + const nudge = async (to: string, content: string) => { try { - await respondToOffer(offer, status); - // Best-effort nudge to the buyer via NIP-17 DM. - try { - await sendDM({ - recipients: offer.buyer, - content: - status === 'accepted' - ? '✅ Your offer was accepted! Message the seller to arrange the handoff.' - : '❌ Your offer was declined.', - }); - } catch { - /* DM optional */ - } - toast({ - title: status === 'accepted' ? 'Offer accepted' : 'Offer declined', - description: 'The buyer will see your response.', - }); + await sendDM({ recipients: to, content }); + } catch { + /* DM is a best-effort extra nudge; the event itself already carries it */ + } + }; + + const respond = async (thread: OfferThread, status: 'accepted' | 'declined') => { + setBusy(thread.latest.id); + try { + await respondToOffer(thread.latest, status); + await nudge( + thread.latest.author, + status === 'accepted' + ? '✅ Your offer was accepted! Message to arrange the handoff.' + : '❌ Your offer was declined.', + ); + toast({ title: status === 'accepted' ? 'Offer accepted' : 'Offer declined' }); } catch (e) { toast({ title: 'Could not respond', @@ -81,10 +96,10 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i } }; - const withdraw = async (offer: MarketplaceOffer) => { - setBusy(offer.id); + const withdraw = async (thread: OfferThread) => { + setBusy(thread.latest.id); try { - await respondToOffer(offer, 'withdrawn'); + await respondToOffer(thread.latest, 'withdrawn'); toast({ title: 'Offer withdrawn' }); } catch (e) { toast({ @@ -97,94 +112,159 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i } }; - // Seller view: incoming offers on their listing. - if (isOwner) { - const incoming = data.offers.filter((o) => o.seller === user.pubkey); - if (incoming.length === 0) return null; - return ( - - -

- Offers ({incoming.length}) -

- {incoming.map((offer) => { - const response = data.responses.find((r) => r.offerId === offer.id); - return ( -
-
- - {offer.amount ? ( - - {offer.amount.toLocaleString()} {offer.currency ?? 'SATS'} - - ) : null} - {response ? statusBadge(response.status) : statusBadge(offer.status)} + const sendCounter = async (thread: OfferThread) => { + const amount = Number(counterAmount); + if (!counterAmount.trim() || !Number.isFinite(amount) || amount <= 0) { + toast({ title: 'Enter a counter amount', variant: 'destructive' }); + return; + } + setBusy(thread.latest.id); + try { + await counterOffer({ + listing: { pubkey: item.pubkey, identifier: item.identifier }, + previous: thread.latest, + amount, + }); + await nudge(thread.latest.author, `↩️ Counter-offer: ${amount.toLocaleString()} sats`); + toast({ title: 'Counter-offer sent', description: 'It’s their turn now.' }); + setCounterFor(null); + setCounterAmount(''); + } catch (e) { + toast({ + title: 'Could not send the counter', + description: e instanceof Error ? e.message : 'Something went wrong.', + variant: 'destructive', + }); + } finally { + setBusy(null); + } + }; + + return ( + + +

+ {isOwner ? `Offers (${mine.length})` : 'Your negotiations'} +

+ + {mine.map((thread) => { + const latest = thread.latest; + const other = latest.author === user.pubkey + ? latest.buyer === user.pubkey + ? latest.seller + : latest.buyer + : latest.author; + const myTurn = thread.awaiting === user.pubkey; + const history = thread.messages.slice(0, -1); + + return ( +
+
+ + + + {statusBadge(latest.status)} + {amountLabel(latest)} +
+ + {/* Earlier rounds — the volley, so the haggling is visible. */} + {history.length > 0 && ( +
+ {history.map((m) => ( +

+ {m.author === user.pubkey ? 'You' : 'Them'}: {amountLabel(m)} + {m.status !== 'open' ? ` · ${m.status}` : ''} +

+ ))}
- {offer.message &&

{offer.message}

} - {!response && offer.status === 'open' && ( + )} + + {latest.message &&

{latest.message}

} + + {/* My turn: accept, decline, or counter. */} + {myTurn && ( + <>
+
- )} -
- ); - })} -
-
- ); - } + {counterFor === latest.id && ( +
+ setCounterAmount(e.target.value)} + placeholder="Your counter (sats)" + className="h-9 border-white/15 bg-white/5 text-sm text-white" + /> + +
+ )} + + )} - // Buyer view: my own offer + the seller's response. - const myOffer = data.offers.find((o) => o.buyer === user.pubkey); - if (!myOffer) return null; - const response = data.responses.find((r) => r.offerId === myOffer.id); - - return ( - - -

- Your offer -

-
- {myOffer.amount ? ( - - {myOffer.amount.toLocaleString()} {myOffer.currency ?? 'SATS'} - - ) : ( - Offer - )} - {statusBadge(response ? response.status : myOffer.status)} -
- {response?.message &&

{response.message}

} - {!response && myOffer.status === 'open' && ( - - )} + {/* Their turn: waiting, with the option to pull my own offer. */} + {!myTurn && latest.status === 'open' && ( +
+

+ Waiting on — your turn will come back if they counter. +

+ {latest.author === user.pubkey && ( + + )} +
+ )} +
+ ); + })} ); diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index de9e168..f07df77 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -11,10 +11,12 @@ import { CrewInviteCard } from '@/components/CrewInviteCard'; import { JoinRequestCard } from '@/components/JoinRequestCard'; import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard'; import { ActivityNotificationCard } from '@/components/ActivityNotificationCard'; +import { TradeNotificationCard } from '@/components/TradeNotificationCard'; import { ImageWithFallback } from '@/components/ImageWithFallback'; import { EmptyState as SharedEmptyState } from '@/components/EmptyState'; import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications'; import { useActivityNotifications } from '@/hooks/useActivityNotifications'; +import { useTradeNotifications } from '@/hooks/useTradeNotifications'; import { useNotificationsRead } from '@/hooks/useNotificationsRead'; import { useNewsletterIssues } from '@/hooks/useNewsletterIssues'; import { useNewsletterRead } from '@/hooks/useNewsletterRead'; @@ -207,6 +209,10 @@ export function NotificationBell({ className }: { className?: string }) { const { notifications: activity, isLoading: activityLoading } = useActivityNotifications(); const { lastRead, seenAt, markAllRead } = useNotificationsRead(); + // Buying/selling steps: an offer on your listing, a counter it's your turn to + // answer, or a request to confirm a completed trade. + const { notifications: tradeNotifs, isLoading: tradeLoading } = useTradeNotifications(user?.pubkey); + // Newsletter mailbox — only nags subscribers, but always browsable in the tab. const { data: newsletterIssues = [], isLoading: newsletterLoading } = useNewsletterIssues(); const { lastRead: newsletterLastRead, markNewsletterRead } = useNewsletterRead(); @@ -238,6 +244,12 @@ export function NotificationBell({ className }: { className?: string }) { [activity, lastRead], ); + // Trades are actionable like crew items — they count until acknowledged. + const unreadTrades = useMemo( + () => tradeNotifs.filter((t) => t.createdAt > seenAt).length, + [tradeNotifs, seenAt], + ); + // Unread newsletter issues (only counts for subscribers). const unreadNewsletter = useMemo( () => @@ -248,7 +260,7 @@ export function NotificationBell({ className }: { className?: string }) { ); // Badge = only UNACKNOWLEDGED items, so "Mark read" actually clears it. - const badgeTotal = unreadCrew + unreadActivity + unreadNewsletter; + const badgeTotal = unreadCrew + unreadActivity + unreadNewsletter + unreadTrades; // Opening the bell marks social + crew as read (and newsletter, since the // News tab is right there). Newsletter read state also clears on the /newsletter page. @@ -259,7 +271,7 @@ export function NotificationBell({ className }: { className?: string }) { } }, [open, markAllRead, markNewsletterRead]); - const isLoading = crewLoading || activityLoading; + const isLoading = crewLoading || activityLoading || tradeLoading; if (!user) return null; @@ -373,10 +385,19 @@ export function NotificationBell({ className }: { className?: string }) { Checking for updates…
)} - {!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && ( - + {!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && tradeNotifs.length === 0 && ( + )}
+ {/* Buying/selling steps first — they're the most time-sensitive. */} + {tradeNotifs.map((n) => ( + seenAt} + onNavigate={() => setOpen(false)} + /> + ))} {joinRequests.map(({ request, crew, endorsements }) => ( ))} diff --git a/src/components/OsNotificationBridge.tsx b/src/components/OsNotificationBridge.tsx index a7ded11..40af065 100644 --- a/src/components/OsNotificationBridge.tsx +++ b/src/components/OsNotificationBridge.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useActivityNotifications, type ActivityNotification } from '@/hooks/useActivityNotifications'; +import { useTradeNotifications, tradeNotificationText, type TradeNotification } from '@/hooks/useTradeNotifications'; import { useCrewNotifications } from '@/hooks/useCrewNotifications'; import { useNewsletterIssues } from '@/hooks/useNewsletterIssues'; import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription'; @@ -190,6 +191,23 @@ function dmCandidate(envelope: DmEnvelope): Candidate { }; } +function tradeCandidate(n: TradeNotification): Candidate { + const title = + n.type === 'trade-request' + ? 'Confirm a trade' + : n.type === 'counter' + ? 'Counter-offer' + : 'New offer on your listing'; + return { + key: `trade:${n.id}`, + source: 'trades', + createdAt: n.createdAt, + title, + body: tradeNotificationText(n), + path: `/listing/${n.listingPubkey}/${n.listingIdentifier}`, + }; +} + function newsletterCandidate(issue: SkateNewsletterIssue): Candidate { return { key: `newsletter:${issue.id}`, @@ -239,12 +257,14 @@ export function OsNotificationBridge() { function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) { const navigate = useNavigate(); + const { user } = useCurrentUser(); const { notifications: activity, isLoading: activityLoading } = useActivityNotifications(); const { invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } = useCrewNotifications(); const { data: issues = [], isLoading: newsletterLoading } = useNewsletterIssues(); const { isSubscribed } = useNewsletterSubscription(); const { data: envelopes = [], isFetched: dmFetched } = useDmEnvelopes(true); + const { notifications: tradeNotifs, isLoading: tradeLoading } = useTradeNotifications(user?.pubkey); // Loaded lazily and mutated directly (never state) so bookkeeping can't cause // a render loop. @@ -275,6 +295,7 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) { [issues, isSubscribed], ); const dmItems = useMemo(() => envelopes.map(dmCandidate), [envelopes]); + const tradeItems = useMemo(() => tradeNotifs.map(tradeCandidate), [tradeNotifs]); useEffect(() => { const seen = seenRef.current; @@ -285,6 +306,7 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) { { name: 'crew', ready: !crewLoading, items: crewItems }, { name: 'newsletter', ready: !newsletterLoading, items: newsletterItems }, { name: 'dms', ready: dmFetched, items: dmItems }, + { name: 'trades', ready: !tradeLoading, items: tradeItems }, ]; let changed = false; @@ -326,10 +348,12 @@ function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) { crewItems, newsletterItems, dmItems, + tradeItems, activityLoading, crewLoading, newsletterLoading, dmFetched, + tradeLoading, prefs, ]); diff --git a/src/components/TradeNotificationCard.tsx b/src/components/TradeNotificationCard.tsx new file mode 100644 index 0000000..0328006 --- /dev/null +++ b/src/components/TradeNotificationCard.tsx @@ -0,0 +1,64 @@ +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { ArrowLeftRight, Handshake, BadgeCheck } from 'lucide-react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { tradeNotificationText, type TradeNotification } from '@/hooks/useTradeNotifications'; +import { cn } from '@/lib/utils'; + +const TYPE_META: Record< + TradeNotification['type'], + { icon: typeof Handshake; className: string; label: string } +> = { + offer: { icon: Handshake, className: 'bg-[#ccff00]/15 text-[#ccff00]', label: 'New offer' }, + counter: { icon: ArrowLeftRight, className: 'bg-orange-500/15 text-orange-400', label: 'Counter-offer' }, + 'trade-request': { icon: BadgeCheck, className: 'bg-teal-500/15 text-teal-300', label: 'Confirm a trade' }, +}; + +/** A buying/selling step in the notification bell. */ +export function TradeNotificationCard({ + notification, + unread, + onNavigate, +}: { + notification: TradeNotification; + unread: boolean; + onNavigate?: () => void; +}) { + const author = useAuthor(notification.counterparty); + const name = author.data?.metadata?.name ?? genUserName(notification.counterparty); + const pic = author.data?.metadata?.picture; + const meta = TYPE_META[notification.type]; + const Icon = meta.icon; + const to = `/listing/${notification.listingPubkey}/${notification.listingIdentifier}`; + + return ( + +
+ +
+
+

{meta.label}

+

+ + + {name.slice(0, 2).toUpperCase()} + + {name} +

+

{tradeNotificationText(notification)}

+

+ {notification.type === 'trade-request' ? 'Open listing' : 'Review the offer'} → +

+
+ + ); +} diff --git a/src/hooks/useMarketplaceOffers.ts b/src/hooks/useMarketplaceOffers.ts index b55484b..cd43a9e 100644 --- a/src/hooks/useMarketplaceOffers.ts +++ b/src/hooks/useMarketplaceOffers.ts @@ -6,16 +6,21 @@ import { useSkateRelays } from '@/hooks/useSkateRelays'; import { MARKETPLACE_ITEM_KIND } from '@/lib/types'; /** - * Marketplace offers — a buyer's structured proposal ("I'll give X for this"), - * which the seller can accept or decline. SkateSpots-native, documented in + * Marketplace offers — a structured negotiation over a listing, documented in * `NIP.md`. * - * Kind `30403` (addressable): - * offer → d = `:offer:`, a = listing, p = seller - * response → d = `:offer-response:`, a = listing, p = buyer, - * e = the buyer's offer event id - * state lives in a `status` tag: open | accepted | declined | withdrawn. + * Kind `30403` (addressable), one event per message: + * a = the listing (`30402::`) + * p = the OTHER party + * e = the message this one answers (a counter / an accept / a decline) + * role = 'offer' (sent by the buyer) | 'response' (sent by the seller) + * status = open | accepted | declined | withdrawn + * amount = SATS + * Every message uses a UNIQUE `d`, so the whole back-and-forth is a durable + * thread (both sides can counter as many times as they like). The current state + * of a negotiation is simply its newest message. */ + export const OFFER_KIND = 30403; export type OfferStatus = 'open' | 'accepted' | 'declined' | 'withdrawn'; @@ -24,7 +29,7 @@ export type OfferRole = 'offer' | 'response'; export interface MarketplaceOffer { id: string; role: OfferRole; - /** Author: the buyer for an offer, the seller for a response. */ + /** Author of this message (buyer for `offer`, seller for `response`). */ author: string; buyer: string; seller: string; @@ -35,8 +40,8 @@ export interface MarketplaceOffer { currency?: string; status: OfferStatus; message: string; - /** The offer event id this response answers (responses only). */ - offerId?: string; + /** The message this one answers (`e`), when it's a counter/accept/decline. */ + replyTo?: string; createdAt: number; event: NostrEvent; } @@ -60,7 +65,15 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null { const coord = parseListingCoord(a); if (!d || !a || !coord) return null; - const role: OfferRole = d.includes(':offer-response:') ? 'response' : 'offer'; + // Explicit `role` tag wins; fall back to the legacy `d` marker. + const roleTag = tag(event, 'role'); + const role: OfferRole = + roleTag === 'response' || roleTag === 'offer' + ? roleTag + : d.includes(':offer-response:') + ? 'response' + : 'offer'; + const counterparty = tag(event, 'p'); const amountTag = event.tags.find(([n]) => n === 'amount'); const status = (tag(event, 'status') as OfferStatus) ?? 'open'; @@ -81,7 +94,7 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null { currency: amountTag?.[2], status, message: event.content, - offerId: tag(event, 'e'), + replyTo: tag(event, 'e'), createdAt: event.created_at, event, }; @@ -97,49 +110,92 @@ function dedupeNewest(events: NostrEvent[]): NostrEvent[] { return [...byD.values()]; } -/** All offers + responses for a listing. */ +/** One negotiation between a buyer and the seller, oldest message first. */ +export interface OfferThread { + buyer: string; + seller: string; + listingCoord: string; + messages: MarketplaceOffer[]; + /** The message that currently represents the thread's state. */ + latest: MarketplaceOffer; + /** Whose turn it is to answer (null when accepted/declined/withdrawn). */ + awaiting: string | null; +} + +function toThreads(messages: MarketplaceOffer[]): OfferThread[] { + const byPair = new Map(); + for (const m of messages) { + if (!m.buyer || !m.seller) continue; + const key = `${m.listingCoord}|${m.buyer}`; + const list = byPair.get(key) ?? []; + list.push(m); + byPair.set(key, list); + } + + const threads: OfferThread[] = []; + for (const list of byPair.values()) { + const sorted = [...list].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)); + const latest = sorted[sorted.length - 1]; + // `open` means the other party must answer; any terminal status ends it. + const awaiting = + latest.status === 'open' + ? latest.author === latest.buyer + ? latest.seller + : latest.buyer + : null; + threads.push({ + buyer: latest.buyer, + seller: latest.seller, + listingCoord: latest.listingCoord, + messages: sorted, + latest, + awaiting, + }); + } + return threads.sort((a, b) => b.latest.createdAt - a.latest.createdAt); +} + +/** Every negotiation thread on a listing. */ export function useListingOffers(listing?: { pubkey: string; identifier: string }) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined; - return useQuery<{ offers: MarketplaceOffer[]; responses: MarketplaceOffer[] }>({ + return useQuery<{ threads: OfferThread[] }>({ queryKey: ['listing-offers', coord, getQueryKeySuffix()], enabled: !!coord, staleTime: 60 * 1000, queryFn: async () => { - if (!coord) return { offers: [], responses: [] }; + if (!coord) return { threads: [] }; const events = await querySkateContent( - [{ kinds: [OFFER_KIND], '#a': [coord], limit: 100 }], + [{ kinds: [OFFER_KIND], '#a': [coord], limit: 200 }], { signal: AbortSignal.timeout(8000) }, ); - const parsed = dedupeNewest(events) + const messages = dedupeNewest(events) .map(parseOffer) .filter((o): o is MarketplaceOffer => !!o); - return { - offers: parsed.filter((o) => o.role === 'offer'), - responses: parsed.filter((o) => o.role === 'response'), - }; + return { threads: toThreads(messages) }; }, }); } -/** Offers received by a seller (open, addressed to them). */ -export function useIncomingOffers(sellerPubkey: string | undefined) { +/** Negotiations where it's `pubkey`'s turn to answer (across every listing). */ +export function useIncomingOffers(pubkey: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); - return useQuery({ - queryKey: ['incoming-offers', sellerPubkey, getQueryKeySuffix()], - enabled: !!sellerPubkey, + return useQuery({ + queryKey: ['incoming-offers', pubkey, getQueryKeySuffix()], + enabled: !!pubkey, staleTime: 60 * 1000, queryFn: async () => { - if (!sellerPubkey) return []; + if (!pubkey) return []; const events = await querySkateContent( - [{ kinds: [OFFER_KIND], '#p': [sellerPubkey], limit: 100 }], + [{ kinds: [OFFER_KIND], '#p': [pubkey], limit: 200 }], { signal: AbortSignal.timeout(8000) }, ); - return dedupeNewest(events) + const messages = dedupeNewest(events) .map(parseOffer) - .filter((o): o is MarketplaceOffer => !!o && o.role === 'offer' && o.status === 'open'); + .filter((o): o is MarketplaceOffer => !!o); + return toThreads(messages).filter((t) => t.awaiting === pubkey); }, }); } @@ -148,61 +204,85 @@ export function useOfferActions() { const { user } = useCurrentUser(); const { mutateAsync: publish } = useNostrPublish(); - const createOffer = async ({ + /** + * Publish one negotiation message. `d` is unique per message so the thread is + * append-only (a counter never overwrites the message it answers). + */ + const publishMessage = async ({ listing, + counterparty, amount, currency = 'SATS', note, + status = 'open', + replyTo, }: { listing: { pubkey: string; identifier: string }; + /** The other party — tagged `p` so the message reaches them. */ + counterparty: string; amount?: number; currency?: string; note?: string; + status?: OfferStatus; + replyTo?: string; }) => { if (!user) throw new Error('Log in to make an offer.'); + if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.'); + // A buyer's messages are `offer`s; the listing owner's are `response`s. + const role: OfferRole = user.pubkey === listing.pubkey ? 'response' : 'offer'; const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`; + const tags: string[][] = [ - ['d', `${user.pubkey}:offer:${listing.identifier}`], + ['d', `${user.pubkey}:${role}:${listing.identifier}:${Date.now().toString(36)}`], ['a', coord], - ['p', listing.pubkey], - ['status', 'open'], + ['role', role], + ['status', status], + ['p', counterparty], ['alt', 'SkateSpots: marketplace offer'], ]; + if (replyTo) tags.push(['e', replyTo]); if (amount && amount > 0) tags.push(['amount', String(amount), currency]); - await publish({ - kind: OFFER_KIND, - content: note?.trim() || '', - tags, - }); + + await publish({ kind: OFFER_KIND, content: note?.trim() || '', tags }); }; - /** Seller responds to a buyer's offer (accept/decline), or buyer withdraws. */ - const respondToOffer = async (offer: MarketplaceOffer, status: OfferStatus, note?: string) => { - if (!user) throw new Error('Log in to respond.'); - const isBuyerWithdrawing = user.pubkey === offer.buyer && status === 'withdrawn'; + /** Buyer: open the negotiation. */ + const createOffer = (args: { listing: { pubkey: string; identifier: string }; amount?: number; currency?: string; note?: string }) => + publishMessage({ ...args, counterparty: args.listing.pubkey }); - const tags: string[][] = isBuyerWithdrawing - ? [ - ['d', `${offer.buyer}:offer:${offer.listingIdentifier}`], - ['a', offer.listingCoord], - ['p', offer.seller], - ] - : [ - ['d', `${user.pubkey}:offer-response:${offer.buyer}`], - ['a', offer.listingCoord], - ['p', offer.buyer], - ['e', offer.id], - ]; - tags.push(['status', status], ['alt', 'SkateSpots: marketplace offer']); - const amountTag = offer.event.tags.find(([n]) => n === 'amount'); - if (amountTag && !isBuyerWithdrawing) tags.push(amountTag); - - await publish({ - kind: OFFER_KIND, - content: note?.trim() || offer.message, - tags, + /** Either side answers with a new amount — the counter-offer volley. */ + const counterOffer = (args: { + listing: { pubkey: string; identifier: string }; + previous: MarketplaceOffer; + amount?: number; + currency?: string; + note?: string; + }) => + publishMessage({ + listing: args.listing, + counterparty: args.previous.author, + amount: args.amount, + currency: args.currency, + note: args.note, + status: 'open', + replyTo: args.previous.id, }); - }; - return { createOffer, respondToOffer }; + /** Accept / decline / withdraw — always answering the current message. */ + const respondToOffer = ( + previous: MarketplaceOffer, + status: Extract, + note?: string, + ) => + publishMessage({ + listing: { pubkey: previous.listingPubkey, identifier: previous.listingIdentifier }, + counterparty: previous.author, + amount: status === 'accepted' ? previous.amount : undefined, + currency: previous.currency, + note: note ?? previous.message, + status, + replyTo: previous.id, + }); + + return { createOffer, counterOffer, respondToOffer }; } diff --git a/src/hooks/useTradeNotifications.ts b/src/hooks/useTradeNotifications.ts new file mode 100644 index 0000000..d3170a5 --- /dev/null +++ b/src/hooks/useTradeNotifications.ts @@ -0,0 +1,81 @@ +import { useMemo } from 'react'; +import { useIncomingOffers, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers'; +import { useIncomingTradeRequests } from '@/hooks/useTradeAttestations'; + +/** + * Trade/offer activity for the logged-in user — the buying/selling steps that + * deserve a nudge: a new offer on your listing, a counter-offer it's your turn + * to answer, and a request to confirm a completed trade. + * + * All of it is derived from events already addressed to the user, so it needs no + * extra infrastructure — the same events power the bell and the OS notifications. + */ +export type TradeNotificationType = 'offer' | 'counter' | 'trade-request'; + +export interface TradeNotification { + id: string; + type: TradeNotificationType; + createdAt: number; + /** The other party (buyer for an offer, the requester for a request). */ + counterparty: string; + amount?: number; + currency?: string; + listingPubkey: string; + listingIdentifier: string; + /** Present for offer/counter items, so the UI can act inline. */ + offer?: MarketplaceOffer; +} + +export function useTradeNotifications(pubkey: string | undefined) { + const { data: incoming = [], isLoading: offersLoading } = useIncomingOffers(pubkey); + const { data: requests = [], isLoading: requestsLoading } = useIncomingTradeRequests(pubkey); + + const notifications = useMemo(() => { + const out: TradeNotification[] = []; + + // Offers / counter-offers where it is the user's turn to answer. + for (const thread of incoming) { + const latest = thread.latest; + out.push({ + id: latest.id, + type: latest.replyTo ? 'counter' : 'offer', + createdAt: latest.createdAt, + counterparty: latest.author, + amount: latest.amount, + currency: latest.currency, + listingPubkey: latest.listingPubkey, + listingIdentifier: latest.listingIdentifier, + offer: latest, + }); + } + + // "Please confirm our trade" requests (buyer → seller). + for (const r of requests) { + out.push({ + id: r.event.id, + type: 'trade-request', + createdAt: r.event.created_at, + counterparty: r.requestor, + listingPubkey: r.listing.pubkey, + listingIdentifier: r.listing.identifier, + }); + } + + return out.sort((a, b) => b.createdAt - a.createdAt); + }, [incoming, requests]); + + return { notifications, isLoading: offersLoading || requestsLoading }; +} + +export function tradeNotificationText(n: TradeNotification): string { + switch (n.type) { + case 'offer': + return n.amount ? `offered ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'made an offer'; + case 'counter': + return n.amount ? `countered with ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'sent a counter-offer'; + case 'trade-request': + return 'asked you to confirm a trade'; + default: + return 'needs your attention'; + } +} diff --git a/src/lib/notificationPrefs.ts b/src/lib/notificationPrefs.ts index eb86e98..d6bed78 100644 --- a/src/lib/notificationPrefs.ts +++ b/src/lib/notificationPrefs.ts @@ -9,7 +9,7 @@ * nothing is requested from the OS until the user turns them on in Settings. */ -export type NotificationCategory = 'dms' | 'crew' | 'social' | 'newsletter' | 'reminders'; +export type NotificationCategory = 'dms' | 'crew' | 'social' | 'newsletter' | 'reminders' | 'trades'; export interface NotificationPrefs { /** Master switch. Off until the user explicitly enables notifications. */ @@ -22,6 +22,8 @@ export interface NotificationPrefs { social: boolean; /** New SkateSpots newsletter drops (subscribers only). */ newsletter: boolean; + /** Marketplace offers, counter-offers and trade confirmations. */ + trades: boolean; /** Scheduled event reminders (Android app — fires even when the app is closed). */ reminders: boolean; } @@ -34,6 +36,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { crew: true, social: true, newsletter: true, + trades: true, reminders: true, }; @@ -47,5 +50,6 @@ export const NOTIFICATION_CATEGORIES: Array<{ { key: 'crew', label: 'Crews', hint: 'Invites, join requests and membership confirmations' }, { key: 'social', label: 'Zaps, replies & mentions', hint: 'Reactions, reposts, zaps, replies and event updates' }, { key: 'newsletter', label: 'Newsletter', hint: 'New SkateSpots drops (subscribers only)' }, + { key: 'trades', label: 'Offers & trades', hint: 'Offers, counter-offers and trade confirmations' }, { key: 'reminders', label: 'Event reminders', hint: "A nudge an hour before events you're going to (Android app)" }, ];