SkateSpots/src/hooks/useTradeNotifications.ts
SkateSpots 3f51740f6a fix(map): stop the offline banner sticking; feat(trades): notify trade confirmations
Offline banner: it keyed off a single relay probe (SKATE_RELAY) while the map
reads from several relays, so it reported 'showing saved spots' while fresh data
loaded fine - and its retry only refetched spots, never re-probed, so it appeared
impossible to clear. Now the saved-spots banner is driven by deviceOffline
(!useOnlineStatus) only, is dismissable (re-armed when the connection returns),
and both retries call refetchSpots() AND relay.recheck(). The relay probe still
gates the 'nothing at all' card.

Trade confirmations: usePendingTradeConfirmations now returns createdAt/eventId,
so 'someone confirmed your trade' becomes a trade-confirm notification - the
missing final step. Bell card + OS title cover it; confirming back is what makes
the trade mutual and builds both reputations.
2026-09-29 21:06:04 +07:00

131 lines
4.7 KiB
TypeScript

import { useMemo } from 'react';
import { useOfferThreadsAddressedToMe, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers';
import { useIncomingTradeRequests, usePendingTradeConfirmations } 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, the outcome of YOUR offer (accepted / declined / withdrawn), a
* request to confirm a completed trade, and a counterparty CONFIRMING the trade
* (which is what turns it into mutual, reputation-building proof).
*
* 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'
| 'accepted'
| 'declined'
| 'withdrawn'
| 'trade-request'
| 'trade-confirm';
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;
/** In-kind offer (a swap/barter), when the sender offered an item. */
offerItem?: 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 } = useOfferThreadsAddressedToMe(pubkey);
const { data: requests = [], isLoading: requestsLoading } = useIncomingTradeRequests(pubkey);
const { data: pendingConfirms = [], isLoading: confirmsLoading } = usePendingTradeConfirmations(pubkey);
const notifications = useMemo<TradeNotification[]>(() => {
const out: TradeNotification[] = [];
// 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,
createdAt: latest.createdAt,
counterparty: latest.author,
amount: latest.amount,
currency: latest.currency,
offerItem: latest.offerItem,
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,
});
}
// A counterparty has recorded/confirmed the deal and you haven't mirrored it
// yet — confirming back is what makes it a mutual, reputation-building trade.
for (const p of pendingConfirms) {
out.push({
id: `confirm:${p.eventId}`,
type: 'trade-confirm',
createdAt: p.createdAt,
counterparty: p.seller,
listingPubkey: p.listing.pubkey,
listingIdentifier: p.listing.identifier,
});
}
return out.sort((a, b) => b.createdAt - a.createdAt);
}, [incoming, requests, pendingConfirms]);
return { notifications, isLoading: offersLoading || requestsLoading || confirmsLoading };
}
/** "2,000 SATS + an 8.25 deck" — sats, an item, or both. */
function offerWhat(n: TradeNotification): string {
const parts: string[] = [];
if (n.amount) parts.push(`${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}`);
if (n.offerItem) parts.push(n.offerItem);
return parts.join(' + ');
}
export function tradeNotificationText(n: TradeNotification): string {
const what = offerWhat(n);
switch (n.type) {
case 'offer':
return what ? `offered ${what}` : 'made an offer';
case 'counter':
return what ? `countered with ${what}` : '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';
case 'trade-confirm':
return 'confirmed your trade — confirm back to build both reputations';
default:
return 'needs your attention';
}
}