diff --git a/src/components/OsNotificationBridge.tsx b/src/components/OsNotificationBridge.tsx index 40af065..9c7a116 100644 --- a/src/components/OsNotificationBridge.tsx +++ b/src/components/OsNotificationBridge.tsx @@ -195,9 +195,15 @@ function tradeCandidate(n: TradeNotification): Candidate { const title = n.type === 'trade-request' ? 'Confirm a trade' - : n.type === 'counter' - ? 'Counter-offer' - : 'New offer on your listing'; + : n.type === 'offer' + ? 'New offer on your listing' + : n.type === 'counter' + ? 'Counter-offer' + : n.type === 'accepted' + ? 'Offer accepted 🎉' + : n.type === 'declined' + ? 'Offer declined' + : 'Offer withdrawn'; return { key: `trade:${n.id}`, source: 'trades', diff --git a/src/components/TradeNotificationCard.tsx b/src/components/TradeNotificationCard.tsx index 0328006..4765422 100644 --- a/src/components/TradeNotificationCard.tsx +++ b/src/components/TradeNotificationCard.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; -import { ArrowLeftRight, Handshake, BadgeCheck } from 'lucide-react'; +import { ArrowLeftRight, Handshake, BadgeCheck, CheckCircle2, XCircle, Undo2 } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -9,11 +9,14 @@ import { cn } from '@/lib/utils'; const TYPE_META: Record< TradeNotification['type'], - { icon: typeof Handshake; className: string; label: string } + { icon: typeof Handshake; className: string; label: string; actionable: boolean } > = { - 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' }, + offer: { icon: Handshake, className: 'bg-[#ccff00]/15 text-[#ccff00]', label: 'New offer', actionable: true }, + counter: { icon: ArrowLeftRight, className: 'bg-orange-500/15 text-orange-400', label: 'Counter-offer', actionable: true }, + accepted: { icon: CheckCircle2, className: 'bg-[#ccff00]/15 text-[#ccff00]', label: 'Offer accepted', actionable: false }, + declined: { icon: XCircle, className: 'bg-red-500/15 text-red-400', label: 'Offer declined', actionable: false }, + withdrawn: { icon: Undo2, className: 'bg-gray-700/40 text-gray-400', label: 'Offer withdrawn', actionable: false }, + 'trade-request': { icon: BadgeCheck, className: 'bg-teal-500/15 text-teal-300', label: 'Confirm a trade', actionable: true }, }; /** A buying/selling step in the notification bell. */ @@ -56,7 +59,7 @@ export function TradeNotificationCard({

{tradeNotificationText(notification)}

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

diff --git a/src/hooks/useMarketplaceOffers.ts b/src/hooks/useMarketplaceOffers.ts index cd43a9e..9d10e9c 100644 --- a/src/hooks/useMarketplaceOffers.ts +++ b/src/hooks/useMarketplaceOffers.ts @@ -178,6 +178,30 @@ export function useListingOffers(listing?: { pubkey: string; identifier: string }); } +/** Negotiations where the NEWEST message was authored by the other party — + * i.e. everything addressed to `pubkey` that they haven't answered yet, + * including terminal outcomes (accepted / declined / withdrawn). */ +export function useOfferThreadsAddressedToMe(pubkey: string | undefined) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + + return useQuery({ + queryKey: ['offer-threads-to-me', pubkey, getQueryKeySuffix()], + enabled: !!pubkey, + staleTime: 60 * 1000, + queryFn: async () => { + if (!pubkey) return []; + const events = await querySkateContent( + [{ kinds: [OFFER_KIND], '#p': [pubkey], limit: 200 }], + { signal: AbortSignal.timeout(8000) }, + ); + const messages = dedupeNewest(events) + .map(parseOffer) + .filter((o): o is MarketplaceOffer => !!o); + return toThreads(messages).filter((t) => t.latest.author !== pubkey); + }, + }); +} + /** Negotiations where it's `pubkey`'s turn to answer (across every listing). */ export function useIncomingOffers(pubkey: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); diff --git a/src/hooks/useTradeNotifications.ts b/src/hooks/useTradeNotifications.ts index d3170a5..fb541f4 100644 --- a/src/hooks/useTradeNotifications.ts +++ b/src/hooks/useTradeNotifications.ts @@ -1,16 +1,23 @@ import { useMemo } from 'react'; -import { useIncomingOffers, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers'; +import { useOfferThreadsAddressedToMe, 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. + * to answer, the outcome of YOUR offer (accepted / declined / withdrawn), 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 type TradeNotificationType = + | 'offer' + | 'counter' + | 'accepted' + | 'declined' + | 'withdrawn' + | 'trade-request'; export interface TradeNotification { id: string; @@ -27,18 +34,25 @@ export interface TradeNotification { } export function useTradeNotifications(pubkey: string | undefined) { - const { data: incoming = [], isLoading: offersLoading } = useIncomingOffers(pubkey); + const { data: incoming = [], isLoading: offersLoading } = useOfferThreadsAddressedToMe(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. + // Every step addressed to me: a new offer, a counter it's my turn to answer, + // or the outcome of my own offer (accepted / declined / withdrawn). for (const thread of incoming) { const latest = thread.latest; + const type: TradeNotificationType = + latest.status === 'open' + ? latest.replyTo + ? 'counter' + : 'offer' + : latest.status; out.push({ id: latest.id, - type: latest.replyTo ? 'counter' : 'offer', + type, createdAt: latest.createdAt, counterparty: latest.author, amount: latest.amount, @@ -73,6 +87,12 @@ export function tradeNotificationText(n: TradeNotification): string { 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 'accepted': + return n.amount ? `accepted your offer — ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'accepted your offer'; + case 'declined': + return 'declined your offer'; + case 'withdrawn': + return 'withdrew their offer'; case 'trade-request': return 'asked you to confirm a trade'; default: