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.
131 lines
4.7 KiB
TypeScript
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';
|
|
}
|
|
}
|