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.
This commit is contained in:
SkateSpots 2026-09-29 21:06:04 +07:00
parent c84f0bdeb9
commit 3f51740f6a
6 changed files with 85 additions and 18 deletions

View file

@ -90,7 +90,12 @@ session with **no other memory of this project**. So:
→ the bell's **All** tab (`TradeNotificationCard`, counted in the badge against
`seenAt`) and OS notifications (new **`trades`** category in `notificationPrefs`,
wired in `OsNotificationBridge`). The bell also has a dedicated **Trades tab**
(5th tab, `Handshake`) listing them, with its own count. **Barter is supported
(5th tab, `Handshake`) listing them, with its own count. Types cover **every
step**: `offer`, `counter`, `accepted`, `declined`, `withdrawn`,
`trade-request` (they asked you to confirm) **and `trade-confirm`** (they
*confirmed* — `usePendingTradeConfirmations`, now carrying `createdAt`/`eventId`
so it can be notified; confirming back is what makes the trade mutual and builds
reputation). **Barter is supported
by design**: trade attestations carry `method` (`cash|lightning|trade|other` —
`MarkSoldDialog` picks it), so a swap feeds reputation exactly like a sale.
**Barter is first-class on the offer side too (2026-09-29):** an offer/counter
@ -749,10 +754,15 @@ session with **no other memory of this project**. So:
- `shouldDehydrateQuery` **never persists an empty array** — otherwise one
blip would wipe the persisted map for the next 24h.
- `MapPage` shows "Offline · showing saved spots" + retry, and a "Can't reach
the relays" card instead of the false "No spots mapped yet". Reachability
reuses the existing **`useRelayStatus(url)`** (`lib/relayProbe`, a real
Nostr handshake) plus `useOnlineStatus` — NOT `navigator.onLine` alone,
which is true on a captive-portal Wi-Fi.
the relays" card instead of the false "No spots mapped yet". ⚠️ **Two distinct
signals (fixed 2026-09-29):** the **saved-spots banner is driven by
`deviceOffline` (`!useOnlineStatus()`)** only — it used to also key off the
single `SKATE_RELAY` probe, but the map reads from *several* relays, so it
stuck around online while fresh data loaded fine, and its retry never
re-probed. The banner is also **dismissible** (re-armed when the connection
returns). The relay probe still gates the **"nothing at all" card**
(`relayUnreachable = !online || relay.status === 'offline'`), and both retries
now call `refetchSpots()` **and** `relay.recheck()`.
- **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie
`SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)
+ `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it),

View file

@ -195,7 +195,9 @@ function tradeCandidate(n: TradeNotification): Candidate {
const title =
n.type === 'trade-request'
? 'Confirm a trade'
: n.type === 'offer'
: n.type === 'trade-confirm'
? 'Trade confirmed'
: n.type === 'offer'
? 'New offer on your listing'
: n.type === 'counter'
? 'Counter-offer'

View file

@ -17,6 +17,7 @@ const TYPE_META: Record<
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 },
'trade-confirm': { icon: BadgeCheck, className: 'bg-[#ccff00]/15 text-[#ccff00]', label: 'Trade confirmed', actionable: true },
};
/** A buying/selling step in the notification bell. */

View file

@ -269,7 +269,15 @@ export function usePendingTradeConfirmations(viewerPubkey: string | undefined) {
enabled: !!viewerPubkey,
staleTime: 60 * 1000,
queryFn: async (c) => {
type Pending = { coord: string; listing: { pubkey: string; identifier: string }; seller: string };
type Pending = {
coord: string;
listing: { pubkey: string; identifier: string };
seller: string;
/** When the counterparty attested (drives the notification ordering). */
createdAt: number;
/** The attestation event id — a stable notification key. */
eventId: string;
};
if (!viewerPubkey) return [] as Pending[];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
@ -296,6 +304,8 @@ export function usePendingTradeConfirmations(viewerPubkey: string | undefined) {
coord,
listing: { pubkey: parsed.pubkey, identifier: parsed.identifier },
seller: ev.pubkey,
createdAt: ev.created_at,
eventId: ev.id,
});
}
return pending;

View file

@ -1,12 +1,13 @@
import { useMemo } from 'react';
import { useOfferThreadsAddressedToMe, type MarketplaceOffer } from '@/hooks/useMarketplaceOffers';
import { useIncomingTradeRequests } from '@/hooks/useTradeAttestations';
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), and a
* request to confirm a completed trade.
* 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.
@ -17,7 +18,8 @@ export type TradeNotificationType =
| 'accepted'
| 'declined'
| 'withdrawn'
| 'trade-request';
| 'trade-request'
| 'trade-confirm';
export interface TradeNotification {
id: string;
@ -38,6 +40,7 @@ export interface TradeNotification {
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[] = [];
@ -78,10 +81,23 @@ export function useTradeNotifications(pubkey: string | undefined) {
});
}
return out.sort((a, b) => b.createdAt - a.createdAt);
}, [incoming, requests]);
// 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 { notifications, isLoading: offersLoading || requestsLoading };
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. */
@ -107,6 +123,8 @@ export function tradeNotificationText(n: TradeNotification): string {
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';
}

View file

@ -543,10 +543,22 @@ export default function MapPage() {
// Wi-Fi present ≠ relay reachable (captive portal / relay outage). The utility
// prefers saved spots over a blank map, so we detect that honestly and say so.
// Reuses the existing relay probe (lib/relayProbe) and online/offline events.
// ⚠️ Two different signals on purpose:
// • deviceOffline — the DEVICE has no connection, so the pins really are
// the saved cache. This is what the "showing saved spots" banner means.
// • relayUnreachable — the canonical community relay didn't answer its probe.
// The map reads from several relays, so this can be true while fresh data
// loads fine; use it only for the "nothing at all" card, never the banner
// (that mismatch is why the banner used to stick around online).
const online = useOnlineStatus();
const relay = useRelayStatus(SKATE_RELAY);
const deviceOffline = !online;
const relayUnreachable = !online || relay.status === 'offline';
// Dismissable for the session; re-armed when the connection comes back.
const [offlineBannerDismissed, setOfflineBannerDismissed] = useState(false);
useEffect(() => {
if (online) setOfflineBannerDismissed(false);
}, [online]);
// Spots reported "not a skate spot" by ≥3 distinct accounts with NO community
// signals are hidden from the map pending a human (see useAutoHiddenSpots).
@ -1679,7 +1691,7 @@ export default function MapPage() {
last-known spots from the local cache — this just says so
honestly, and offers a retry. Shown only once we know the relay
is unreachable, so a slow cold start never flashes it. */}
{!isLoading && relayUnreachable && allLocations.length > 0 && (
{!isLoading && deviceOffline && !offlineBannerDismissed && allLocations.length > 0 && (
<div className="absolute top-[4.5rem] left-1/2 z-[1002] -translate-x-1/2 md:top-3">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-400/30 bg-black/75 px-3 py-1.5 shadow-lg backdrop-blur-md">
<WifiOff className="h-3.5 w-3.5 text-amber-400" />
@ -1688,12 +1700,23 @@ export default function MapPage() {
</span>
<button
type="button"
onClick={() => refetchSpots()}
onClick={() => {
refetchSpots();
relay.recheck();
}}
className="ml-0.5 rounded-full p-1 text-amber-200/80 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Try again"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setOfflineBannerDismissed(true)}
className="rounded-full p-1 text-amber-200/60 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
@ -1733,7 +1756,10 @@ export default function MapPage() {
size="sm"
variant="outline"
className="mt-4 gap-2 border-white/20"
onClick={() => refetchSpots()}
onClick={() => {
refetchSpots();
relay.recheck();
}}
>
<RefreshCw className="h-4 w-4" />
Try again