From 3f51740f6a426ede69e83145761100f02ede9bcb Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 29 Sep 2026 21:06:04 +0700 Subject: [PATCH] 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. --- AGENTS.md | 20 ++++++++++---- src/components/OsNotificationBridge.tsx | 4 ++- src/components/TradeNotificationCard.tsx | 1 + src/hooks/useTradeAttestations.ts | 12 ++++++++- src/hooks/useTradeNotifications.ts | 32 +++++++++++++++++----- src/pages/MapPage.tsx | 34 +++++++++++++++++++++--- 6 files changed, 85 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e4be160..6390204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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), diff --git a/src/components/OsNotificationBridge.tsx b/src/components/OsNotificationBridge.tsx index 9c7a116..e03e2c9 100644 --- a/src/components/OsNotificationBridge.tsx +++ b/src/components/OsNotificationBridge.tsx @@ -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' diff --git a/src/components/TradeNotificationCard.tsx b/src/components/TradeNotificationCard.tsx index 4765422..51e87fa 100644 --- a/src/components/TradeNotificationCard.tsx +++ b/src/components/TradeNotificationCard.tsx @@ -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. */ diff --git a/src/hooks/useTradeAttestations.ts b/src/hooks/useTradeAttestations.ts index 4511d0f..88ae749 100644 --- a/src/hooks/useTradeAttestations.ts +++ b/src/hooks/useTradeAttestations.ts @@ -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; diff --git a/src/hooks/useTradeNotifications.ts b/src/hooks/useTradeNotifications.ts index 9a11cc4..b9ed354 100644 --- a/src/hooks/useTradeNotifications.ts +++ b/src/hooks/useTradeNotifications.ts @@ -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(() => { 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'; } diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 4cb1c16..bcab143 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -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 && (
@@ -1688,12 +1700,23 @@ export default function MapPage() { +
)} @@ -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(); + }} > Try again