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.
This commit is contained in:
parent
a6aeb2feb0
commit
7dff8793b8
4 changed files with 68 additions and 15 deletions
|
|
@ -195,9 +195,15 @@ function tradeCandidate(n: TradeNotification): Candidate {
|
||||||
const title =
|
const title =
|
||||||
n.type === 'trade-request'
|
n.type === 'trade-request'
|
||||||
? 'Confirm a trade'
|
? 'Confirm a trade'
|
||||||
|
: n.type === 'offer'
|
||||||
|
? 'New offer on your listing'
|
||||||
: n.type === 'counter'
|
: n.type === 'counter'
|
||||||
? 'Counter-offer'
|
? 'Counter-offer'
|
||||||
: 'New offer on your listing';
|
: n.type === 'accepted'
|
||||||
|
? 'Offer accepted 🎉'
|
||||||
|
: n.type === 'declined'
|
||||||
|
? 'Offer declined'
|
||||||
|
: 'Offer withdrawn';
|
||||||
return {
|
return {
|
||||||
key: `trade:${n.id}`,
|
key: `trade:${n.id}`,
|
||||||
source: 'trades',
|
source: 'trades',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { nip19 } from 'nostr-tools';
|
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -9,11 +9,14 @@ import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const TYPE_META: Record<
|
const TYPE_META: Record<
|
||||||
TradeNotification['type'],
|
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' },
|
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' },
|
counter: { icon: ArrowLeftRight, className: 'bg-orange-500/15 text-orange-400', label: 'Counter-offer', actionable: true },
|
||||||
'trade-request': { icon: BadgeCheck, className: 'bg-teal-500/15 text-teal-300', label: 'Confirm a trade' },
|
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. */
|
/** A buying/selling step in the notification bell. */
|
||||||
|
|
@ -56,7 +59,7 @@ export function TradeNotificationCard({
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-gray-400">{tradeNotificationText(notification)}</p>
|
<p className="mt-0.5 text-xs text-gray-400">{tradeNotificationText(notification)}</p>
|
||||||
<p className="mt-1 text-[10px] font-bold uppercase tracking-wide text-[#ccff00]">
|
<p className="mt-1 text-[10px] font-bold uppercase tracking-wide text-[#ccff00]">
|
||||||
{notification.type === 'trade-request' ? 'Open listing' : 'Review the offer'} →
|
{meta.actionable ? 'Review the offer' : 'Open listing'} →
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -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<OfferThread[]>({
|
||||||
|
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). */
|
/** Negotiations where it's `pubkey`'s turn to answer (across every listing). */
|
||||||
export function useIncomingOffers(pubkey: string | undefined) {
|
export function useIncomingOffers(pubkey: string | undefined) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useIncomingOffers, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers';
|
import { useOfferThreadsAddressedToMe, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers';
|
||||||
import { useIncomingTradeRequests } from '@/hooks/useTradeAttestations';
|
import { useIncomingTradeRequests } from '@/hooks/useTradeAttestations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trade/offer activity for the logged-in user — the buying/selling steps that
|
* 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
|
* 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
|
* 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.
|
* 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 {
|
export interface TradeNotification {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -27,18 +34,25 @@ export interface TradeNotification {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTradeNotifications(pubkey: string | undefined) {
|
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 { data: requests = [], isLoading: requestsLoading } = useIncomingTradeRequests(pubkey);
|
||||||
|
|
||||||
const notifications = useMemo<TradeNotification[]>(() => {
|
const notifications = useMemo<TradeNotification[]>(() => {
|
||||||
const out: TradeNotification[] = [];
|
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) {
|
for (const thread of incoming) {
|
||||||
const latest = thread.latest;
|
const latest = thread.latest;
|
||||||
|
const type: TradeNotificationType =
|
||||||
|
latest.status === 'open'
|
||||||
|
? latest.replyTo
|
||||||
|
? 'counter'
|
||||||
|
: 'offer'
|
||||||
|
: latest.status;
|
||||||
out.push({
|
out.push({
|
||||||
id: latest.id,
|
id: latest.id,
|
||||||
type: latest.replyTo ? 'counter' : 'offer',
|
type,
|
||||||
createdAt: latest.createdAt,
|
createdAt: latest.createdAt,
|
||||||
counterparty: latest.author,
|
counterparty: latest.author,
|
||||||
amount: latest.amount,
|
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';
|
return n.amount ? `offered ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'made an offer';
|
||||||
case 'counter':
|
case 'counter':
|
||||||
return n.amount ? `countered with ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'sent a counter-offer';
|
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':
|
case 'trade-request':
|
||||||
return 'asked you to confirm a trade';
|
return 'asked you to confirm a trade';
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue