From 7dff8793b80febf956ee633d1c161e875602b40d Mon Sep 17 00:00:00 2001
From: SkateSpots
Date: Tue, 29 Sep 2026 18:36:24 +0700
Subject: [PATCH] feat(market): notify the buyer of offer outcomes too
Terminal responses ended the thread early, so 'useIncomingOffers' (awaiting me
only) skipped the accepted/declined outcome of YOUR offer. Added
useOfferThreadsAddressedToMe (any thread whose newest message is theirs) and
classify it: open -> offer/counter, else accepted/declined/withdrawn. Bell card
+ OS notification titles cover the new states.
---
src/components/OsNotificationBridge.tsx | 12 ++++++---
src/components/TradeNotificationCard.tsx | 15 ++++++-----
src/hooks/useMarketplaceOffers.ts | 24 ++++++++++++++++++
src/hooks/useTradeNotifications.ts | 32 +++++++++++++++++++-----
4 files changed, 68 insertions(+), 15 deletions(-)
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: