- HandoffHelper suggests the 3 nearest public skate spots to the listing as
one-tap DM meetup templates (public data only; never the viewer's location).
- New addressable kind 34967 + useShipmentStatus + ShippingStatusCard: seller
marks shipped, buyer received, either issue. p-tagged so the push service
notifies (added to MARKET_KINDS); d=ship:<listing-d>:<author> so a status
replaces only its author's.
- Honest framing: a self-reported claim, NOT proof - 'received' does not release
escrow; no address stored; parcel photos with an address discouraged.
- ['shipping','buyer'|'seller'|'split'] tag + create-page selector.
- Distance from the viewer's last known location (cache only, no GPS prompt) on
both gear cards, and a 'Nearest (local)' sort on GearSwapPage.
- HandoffHelper (buyer-only, in ListingTradeActions) opens the trade DM
pre-filled: suggest a time & place / share delivery details / use a locker,
with anti-scam copy. Details stay in the NIP-17 DM; the app never sees them.
- ships deals now nudge safety on all platforms, not just when escrow is ready.
pow.ts: minePow/verifyPow/countLeadingZeroBits. PoW pays NO ONE - it is a free
CPU tax, not a fee. useNostrPublish accepts pow and mines the nonce on the
UNSIGNED event before signing (id is signature-independent -> one signer prompt).
CreateListingPage sends 16 bits per listing (~80ms) and 20 bits when a newcomer
chooses work instead of the 1,000-sat fee (SpamFeePayment.onPowInstead = the free
alternative). parseMarketplaceItem exposes powDifficulty; the card shows a PoW
badge at >=16.
useTradeIntegrity.ts adds two anti-sybil levers to TradeReputationCard:
- useBatchActivity counts each counterparty's published spots+listings and weights
their contribution by min(1, activity/3), so a ring of empty accounts
contributes ~0 even when cross-confirmed.
- useTradeRings does a 2-hop query of the partners' own attestations and measures
closedness (share of partner-partner trades); >=3 partners with >=0.5 closedness
flags a closed cluster. PoW (NIP-13) remains, but needs mining at publish.
- Listings carry optional ["fulfilment","pickup"|"ships"|"both"] (selling only):
a create-page selector (default pickup), a card badge, and a Pickup/Ships filter
on GearSwapPage. The app NEVER collects an address - a ships deal exchanges it
privately in the NIP-17 trade chat, and ListingTradeActions nudges escrow for
shipped deals. No KYC rail, no labels, no carrier API.
- TradeReputationCard adds a low-diversity ring warning (verified >= 5 across <= 2
people) beyond the single-partner one.
A trade proof of ['proof','zap','<receiptId>'] is now checked by verifyZapReceipt
(kind-9735 signature + p = seller + decodable bolt11 amount + embedded 9734
request) and counted as paymentVerified, shown on TradeReputationCard as
'payment-verified (zap receipt)' - a real tier above the self-reported claim.
Honest ceiling: proves the seller's LNURL provider acknowledged a payment, not
that goods changed hands. NIP.md documents the zap proof variant.
A price_type=fixed listing now shows a direct 'Buy with sats' button in
ListingTradeActions (card + detail) - no negotiation needed. The price resolves
to sats (SATS directly, USD via useUsdToSats) and pays through PayWithSats (NWC ->
WebLN -> Cashu melt, falling back to the seller's lud16). The sheet mounts only
while open so the wallet hooks stay off every grid card. Reuses the accepted-offer
Pay rail.
The proof tag is self-reported and unverifiable client-side, so it must not read
as proof of payment. useTradeReputation now exposes withPaymentProof (mutual
trades carrying a proof), shown on TradeReputationCard as 'with payment proof
(self-reported - not verified)' distinct from the verified count.
- public/push-sw.js: shows the content-free push; tap focuses/opens the app.
- src/lib/pushService.ts + usePushNotifications: register the SW, subscribe with
the service's VAPID key, sign a NIP-98 challenge proof, POST /subscribe.
- NotificationSettingsCard: a 'Background notifications' toggle (opt-in,
per-device) + honest metadata copy; corrected the now-stale 'we run no push
server' note.
- useSendDirectMessage: marks the RECIPIENT wrap ["skatespots","push"] (never
the self copy) and adds the community relay as the notify relay so the push
service sees it.
- /privacy: discloses the push server sees metadata only.
Phases 1-2. The seller may attach a ["payment", value, kind] pointer to the
accepted 30403 message (lninvoice|lnaddress|creq|clink, optional payment_hash);
ListingOffersPanel attaches the seller's own lud16 on accept, falling back to
their profile lud16 on the buyer's side. The pointer rides the accepted event so
it flows through the existing decrypt-free trade notification path (a NIP-17 DM
can't reach the Trades tab without decrypting).
Buyer gets a 'Pay with sats' button -> PayWithSats dialog -> usePayPaymentPointer
pays via NWC -> WebLN -> Cashu melt (mirrors useZaps). Nothing is held or signed
by us; recording the trade stays the existing 31871 flow. creq/clink are
recognized but not payable yet. No new event kind.
Numo (and other POS) emit a BIP21 'bitcoin:?creq=...&lightning=...' QR. The
scanner treated it as 'unknown', so a Numo terminal couldn't be paid at all.
classifyQRCode now parses BIP21, extracting lightning= (paid via the existing
NWC/Cashu-melt path) and creq=, and recognizes standalone NUT-18 creq1.../creqA...
as a new 'cashu_request' type. cashu_request is recognized but not payable yet -
CashuWalletCard shows an honest message. BIP21 parsing avoids URLSearchParams so
a raw creqA... base64 '+' isn't coerced to a space.
The admin backfill lived only in Settings -> Advanced, so an admin staring at
the 'Uncategorised' row had no obvious way to fix it. Render the admin-only
RegionBackfillCard inline in the region section when uncategorised > 0, and
clarify the zero-state (remaining untagged pins belong to other mappers).
Spots created before reverse geocoding have coordinates but no city/country
tag, so the map draws them while /stats can only show "Uncategorised". Counted
on the canonical relay: 54 raw -> 43 after admin-hide -> 13 tagged / 30
uncategorised, and 21 of the 30 are the two admin accounts' own pins.
useRegionBackfill finds the logged-in user's own untagged spots/shops, decodes
each pin's coords (lat/lon else geohash), reverse-geocodes via the shared
reverseGeocodeRegions(), and republishes the addressable event with the region
tags added - preserving every existing tag. Publishes to getWriteRelays()
(community + author) so the canonical stats see it, spaced 1.1s for Nominatim's
1 req/s policy. Surfaced as an admin-only RegionBackfillCard in Settings ->
Advanced.
Two Nostr-native auth upgrades (read-only npub sessions were declined).
NIP-49 (src/lib/ncryptsec.ts):
- Login: the Secret Key tab gains an 'Encrypted (NIP-49)' mode — paste
ncryptsec1… + password -> decrypt -> login.
- Signup: the done step's key card exports a password-protected backup, which
is safe to keep in cloud storage (unlike a raw nsec).
- Verified in Node: round-trips; wrong password, non-ncryptsec and empty
password all rejected.
NIP-46 nostrconnect (src/lib/nostrConnect.ts):
- Desktop -> phone pairing via QR. Nostrify implements only the bunker
direction; nostrconnect is signer-initiated, so the handshake lives here
(ephemeral client keypair + secret, subscribe kind 24133 #p:[clientPub],
reply {id,result:secret} signed with the ephemeral key), then hands the
derived bunker:// URI to login.bunker().
- Try NIP-44 then NIP-04 (the transport moved NIP-04 -> NIP-44; support varies).
- LoginDialog's Bunker tab leads with the QR; bunker:// paste stays a fallback.
- NOT verified against a real signer — needs an on-device Amber check.
Docs: AGENTS.md.
Owner feedback: the route dialog's map still dominated the phone screen, and
the 'nothing leaves to Google or Apple Maps' tagline cluttered the chrome.
- Map: 50vh -> 34vh on mobile (min-h 300 -> 220); desktop 440 -> 400. Follow
mode 60vh -> 54vh, so the bigger map is kept only when you need it.
- Drop the visible tagline from the route header (kept sr-only for a11y); the
promise now reads on /privacy -> 'What leaves your device' -> Directions
('routing stays in the app; start/destination are never handed off to Google
or Apple Maps').
Docs: AGENTS.md map-layout note.
Builds out the rest of the transit UX from data Transitous actually provides.
- Time picker: Leave now / Depart at / Arrive by (MOTIS time + arriveBy).
- Mode filter: Bus/Tram/Metro/Train/Ferry groups -> MOTIS transitModes.
- Stop departure boards: tap a stop name -> MOTIS stoptimes, with live and
cancelled badges and a feed-provided platform hint.
- Fares: MOTIS carries NO prices (verified live: debugOutput.fares=0,
agencyFareUrl empty), so a 'tickets' link is shown ONLY when the feed supplies
an operator fare URL - never an invented price.
New src/lib/transitUi.ts holds the mode vocabulary (emoji/colour/groups) so the
itinerary and the board cannot drift; StopDepartures.tsx is its own dialog.
Docs: AGENTS.md transit note.
Transit showed one cryptic summary line and threw away 3 of 4 itineraries.
Surface what the MOTIS/Transitous response already contains.
routing.ts: TransitLeg gains headsign, agencyName, from/to track, stop count,
realTime and distance; RouteResult gains transitOptions (all itineraries,
fastest first). fetchTransitRoute maps every itinerary (the API returns more
than numItineraries).
RouteDialog:
- Alternatives row — tap to switch; a dedicated effect redraws the map from the
selected itinerary, so switching never re-queries the service.
- Leg-by-leg itinerary: coloured line badge, headsign, board/alight stop + clock
times, platform track, N stops, operator, and a 'live' badge only when the
feed is realtime.
- Summary now reflects the selected option and shows the option count.
- 'Whole route' re-fit follows the selection.
Also clears the 2 pre-existing eslint warnings (unused StatusIcon/nip19) so the
documented zero-warning baseline holds.
Docs: AGENTS.md transit-detail note (incl. Transitous 403s a generic UA).
Signup and onboarding are the first impression, so tighten both.
Signup (SignupDialog):
- Add a Choose -> Profile -> Save key step indicator.
- Make backup a confirmation, not a skip: an "I've saved my recovery code"
checkbox enables "Let's skate" (downloading the key also sets it); the old
co-equal "Skip for now" is now a quiet link.
- Fix copy that contradicted step 3 ("nothing to remember") and warn that a
signer may ask to approve the profile publish.
- Platform-aware signer nudge instead of implying a recommendation.
LoginArea: drop the dead SignupMethodDialog/MnemonicSignupDialog wiring (never
opened) and rename the misleading nsecSignupDialogOpen state.
Onboarding: add a "Where do you skate?" first step (Use my location caches the
fix so the map opens on the skater's area) and remove the dead disabled
"Log in first" button.
lib/offlineCache already had a pendingPosts table, a sync hook and an
OfflineIndicator badge, but nothing ever called queuePost - so an offline action
just failed. Now it queues.
- useNostrPublish: if isOffline() (no 15s publish timeout), or every relay
fails, queue the ALREADY-SIGNED event and resolve. Signing happened at the
moment of the action, so Amber is never re-prompted at sync time.
- queueSignedEvent: supersedes older queued replaceable (0/3/10000+) and
addressable (30000+) events, prunes stale ephemeral kinds (2259/24899, 15 min,
a late 'I'm here' is worse than none) and caps at 100.
- useSyncPendingPosts: publishes the stored signature directly (never through
useNostrPublish, which would re-queue and duplicate), re-signs only legacy
unsigned entries, retries every 30s, and returns failed rows so one failure
can't strand an action.
- A module-level lock keeps ONE publisher even though OfflineIndicator is
mounted twice (mobile + desktop headers).
- Indicator copy now says 'queued actions' and lists what works offline.
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.
Listings already supported price_type=trade, and attestations carry method=trade,
so swaps counted for reputation. Offers, though, could only name a sats amount.
- 30403 messages may now carry an optional 'item' tag: what the sender is
offering in kind. It combines with amount (e.g. 2,000 sats + an 8.25 deck) or
stands alone for a pure swap.
- OfferDialog: 'Or trading something' field + validation (amount, item or note).
- ListingOffersPanel: the thread shows the offered item, and the counter UI takes
an optional item alongside the amount.
- Bell/OS text reads it ('offered 2,000 SATS + an 8.25 deck').
- NIP.md documents the item tag; AGENTS notes barter is now first-class.
The bell had All/Social/News/Crews; trade steps were only inside All. Added a
5th tab (Handshake, orange accent) listing offers, counter-offers, outcomes and
trade confirmations, with its own count. All still shows them first.
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.
Offers were one-way (buyer proposes, seller accepts/declines) and fixed-slot
addressable events, so the buyer could never counter back and the haggling left
no trace. Now it's a thread.
- useMarketplaceOffers: every message gets a UNIQUE d (append-only) with a
'role' tag (offer = buyer / response = seller), 'e' = the message it answers,
and status. New counterOffer() plus respondToOffer(previous, status). Parsing
still accepts the legacy fixed-slot d form when the role tag is absent.
- useListingOffers returns OfferThread[] (oldest first) with = whose
turn it is; useIncomingOffers returns the threads awaiting me (across listings).
- ListingOffersPanel: shows the volley, and accept/decline/counter (inline amount)
for whichever side's turn it is, with withdraw on your own open offer. Counter
notifications are nudged over DM too.
- New useTradeNotifications: incoming offers, counters and 'confirm our trade'
requests, derived from events already addressed to the user.
- Bell: trade items render first in All and count toward the badge (vs seenAt).
- OS notifications: new 'trades' category in notificationPrefs + a trades source
in OsNotificationBridge.
- NIP.md updated for the thread model (legacy form documented).
The dialog's absolute close button overlapped the right edge of the new
destination/search header row, so tapping the SEARCH pill could land on the
close button instead. Give the header right padding so the row ends before it.
(Verified end-to-end in the production build by driving a real spot page:
Directions -> Search opens the panel, no console errors.)
Tap the destination in the header to search and change where you're going:
- New DestinationSearch: searches skate spots, skate shops and BTCMap merchants
(merchants come from a radius around the current destination - BTCMap has no
global name search - and are labelled as such). Mounted only while the search
is open, so opening Directions pays nothing for it; spot/shop limits match the
map page so the cache is reused.
- RouteDialog now owns the destination internally (seeded from the prop on open,
not on every render) so the search can change it without re-opening the dialog,
and calls onDestinationChange so the parent stays in sync (wired in MapPage).
- The Leaflet map is no longer rebuilt when the destination changes: it's created
from a ref once per open, and a small effect just moves the destination marker
(the route effect re-fits the view). This avoids a teardown/recreate flash.
SheetContent renders a built-in close X, and SpotBottomSheet adds its own in
the header row - so tapping a spot showed two. Added an opt-in hideClose prop
to SheetContent and used it on the spot sheet (which keeps its own, placed in
the header). Also padded the new mint sheet header clear of the built-in close.
- Mints: the whole row is now clickable and opens a detail sheet with every
field the directory has (status, latency, NUTs, version, last online,
description), Copy URL / Open / Terms actions, an Add button, and an honest
'what is a mint + mints can rug' explainer.
- Crews: replaced the horizontally-scrolling category chips + More/Less toggle
with one compact type control that opens a bottom sheet listing every focus
(single-select). No more swiping the chips on a phone, and nothing that reads
as a category when it isn't.
- Directions: the flash was three things - the dialog's scale-in animation while
Leaflet measured itself, a bg-muted map box showing before tiles load, and an
animated fitBounds jump when the route arrived. Fixed with a second
invalidateSize after the animation, a dark map background, and
animate:false on the route fit. Following is smoother too: re-centre only
after ~6m of movement, with a gentle 0.6s pan, so GPS jitter doesn't twitch
the map.
- Directions: the Distance/Time/Arrive tiles are now a compact one-line strip
(StatTile removed), and the dialog's DOM order matches its visual order
(header, map, vehicle row, From row, details, pinned Start) with redundant
order-* so a dev CSS-timing hiccup can't scramble it.
- Crews: the filter expand toggle now has a divider and always shows its
More/Less label, so it no longer reads as another crew category.
Three phone issues from a screenshot review:
- Map: the floating toolbar sat on top of Leaflet's top-left zoom control.
index.css now offsets .leaflet-top.leaflet-left by 4.25rem under max-width
767px, scoped to the map page via a new class
(desktop toolbar is in flow, so no offset). Verified at 390x844.
- Sasquatch: the cramped red 8px 'Go Pro' chip is now a proper lime pill with
the bolt, matching the panel.
- Directions: the map was 38vh and buried under the mode row. It's now 50vh
(440px on larger screens; 60vh/62vh while following) and sits directly under
the header, with the mode/vehicle row and 'From' row moved BELOW it. Done with
flex order-* utilities rather than moving JSX; Start bar stays pinned.
Follow mode was a live dot on a north-up map you had to keep watching. Now it
guides:
- Voice: device TTS via speechSynthesis (free, offline, no server, no
permission), preferring an on-device voice in the user's language. Speaks at
~150 m and ~40 m before each turn (once per band, per step) and on arrival.
Unlocked by the Start tap; mute toggle persisted in skatespots:route-voice.
- Wake lock: navigator.wakeLock keeps the screen on while following, released on
stop/unmount and re-acquired on visibilitychange.
- Arrival: within ~30 m -> "You've arrived", haptic, auto-stop, Done button.
- Haptics: navigator.vibrate on the 150 m / 40 m bands and arrival.
- Layout: while following the map grows to 58vh (60vh sm) and the planning
step-list steps out of the way; the NEXT banner is bigger.
- Copy updated: no longer claims 'no voice guidance'.
Still absent: auto-reroute if you leave the route. True screen-off navigation
needs a native foreground service (app must stay foregrounded).
Mobile map was paying for a stacked toolbar under the header, leaving the map
~78% of the viewport.
- MapPage: the toolbar is absolute over the map on mobile (gradient fade,
pointer-events pass-through) and static on desktop (md:static); controls get
translucent/blurred backgrounds for legibility over the map.
- Container height now subtracts header + bottom nav only (desktop footer
subtraction removed); Layout hides the desktop footer on full-bleed routes.
- Loading pill / offline banner nudged below the floating bar on mobile.
Measured at 390x844 (headless): map starts under the header and fills 730px =
86.5% of the viewport (was ~78%).
The transport treats a crew's wss:// coordinator URL AS A RELAY, and an https://
one falls back to the public wss://relay.contextvm.org. So 'nothing crew-specific
hits a public relay' was false for https coordinators — it put ciphertext +
metadata (crew coord, timing) on a public relay.
- CrewCoordinatorCard: shows the actual relay in use, and states honestly
whether it's the crew's own or the shared public one.
- CrewForm: the coordinator field now says a wss:// endpoint is the crew's own
relay and an https:// one routes over the shared public relay.
- AGENTS.md: record the relay-vs-coordinator transport reality so this isn't
re-derived (and Cordn RPC is not called).
Carrier is the existing kind-0 'cordn' field (app convention, NIP.md) — no new
kind. It now holds a coordinator *pointer* rather than only a URL.
- EditProfileForm: accepts nprofile / cordn.net link / npub / hex / http URL,
validates via parseCoordinatorInput, normalises a bare pointer to Cordn's
canonical add-link on save, and adds 'Use my coordinator'. Label -> Coordinator.
- NIP19Page: when the profile's cordn value parses as a pointer, render a
Coordinator row with a one-tap Add (sets the viewer's coordinator, with toast)
and an 'Open in Cordn' link; otherwise keep the old plain-link row.
- NIP.md: document the broadened cordn field (pointer formats + one-tap add).
- smoke test: now boots a real profile route too.
Crew-level Cordn chat already existed (lib/cordn, useCordnChat, CrewChat,
CrewCoordinatorCard, the crew 'coordinator' tag). The real gap: the crew form
accepted a URL only, so a coordinator run from Cordn's phone app — which yields
an nprofile, not a URL — could not be entered at all.
- lib/coordinator.ts: parseCrewCoordinatorInput / crewCoordinatorInputValue —
the same pointer formats as Settings -> Coordinator (nprofile / cordn.net
link / npub / hex) plus a plain endpoint URL.
- CrewForm: parses the pointer, shows live valid/invalid feedback, sets
['coordinator', url, pubkey?] (schema unchanged), and gains a one-tap
'Use my coordinator' prefill from Settings.
- NIP.md: note the accepted pointer formats on the coordinator tag.
- AGENTS.md: record that crew-level Cordn chat already exists (the lesson:
grep src before building).
SkateSpots stays a Cordn *client*: it points at a coordinator the user
chooses or runs, and hands phone-hosting off to Cordn's own app. No embedded
coordinator, no default SkateSpots coordinator (deliberate: liability).
- src/lib/coordinator.ts: Cordn's own formats — an nprofile pointer, the
canonical add-link https://cordn.net/chat/coordinators?c=<nprofile> (from
Cordn packages/server/src/main.ts), default relay wss://relay.contextvm.org,
the docker one-liner. Parses nprofile/npub/hex/link; encodes an add-link.
- src/hooks/useCoordinator.ts: device-local store + presence probe. Health is
NOT Cordn RPC — it queries the coordinator's relays for authors:[pubkey] (its
ContextVM announcement/heartbeat), so a private/unannounced coordinator
honestly reads 'Not seen' rather than failing.
- src/components/CoordinatorSettingsCard.tsx + Settings -> Coordinator:
empty/connected states, add by paste or QR scan, add-link copy + QR, and a
'Run your own' hand-off (open Cordn, scan its QR, copy the docker command).
- smoke test now boots /settings?section=coordinator.
The profile screen was a bare 'view my public profile' link, a banner that
wrongly said 'Profile not loaded' whenever the user simply had no name yet,
a directory card, then the form.
- ProfilePreviewCard: banner + avatar + handle + bio + badges, so you can see
how you look to everyone else without leaving the page. The public-profile
link moves into it.
- The reload banner now only shows when we have NO metadata at all, and says so
honestly instead of keying off a missing name.
- ProfileChecklist: a compact nudge listing only genuinely empty essentials
(name, photo, bio, lightning address, handle); hidden once complete.
An OS alarm an hour before each upcoming event the user RSVP'd going or
interested to. Because the OS holds the alarm it fires with the app fully
closed — the only closed-app notification achievable without a server.
- src/lib/reminders.ts: schedule/cancel via @capacitor/local-notifications,
tracking handed-out ids in localStorage so withdrawn RSVPs and passed events
are cancelled without disturbing one-off notifications. Web is a no-op.
- src/hooks/useLocalReminders.ts: reuses the existing upcoming-events and
my-RSVPs queries; no new relay destination, no signing.
- Bridge mounts the hook only on native and only when the new 'reminders'
pref is on; unmount cancels everything (opt-out/logout).
- Skipped the Pro-expiry reminder on purpose: it would drag the heavy
useSasquatchPro query into a nudge.
Bumps to v1.5.7 (versionCode 10507); APK rebuilt.
Mirrors the activity the app already computes (bell, crews, DMs, newsletter)
to the OS notification shade, with no new relay traffic and no new network
destination.
- src/lib/osNotify.ts: one delivery API. Web uses the Notification API; native
uses @capacitor/local-notifications (lazily imported, so the web bundle never
pays for it). Tap-through navigates in-app.
- src/components/OsNotificationBridge.tsx: mounted once in Layout, inert until
opted in. Per-source silent priming + a seen-id set means history is never
replayed as a flood, and it stays quiet while the app is visible (the bell
already covers that).
- DMs notify from the gift-wrap ENVELOPE only (src/hooks/useDmEnvelopes.ts,
no decryption) — so Amber is never prompted just to be told you have a
message. Preview is honestly generic.
- src/lib/notificationPrefs.ts + useNotificationPrefs: device-local, opt-in,
default off, per-category toggles. Settings -> Notifications UI.
Deliberately local-only: a fully closed app needs a server push, which is not
built (Phase 2 = on-device background runner; Phase 3 = content-free FCM).
The spot/tour/event/crew marker effect rebuilt every Leaflet marker
whenever the parent handed it new arrays, and added each marker with a
per-marker clusterGroup.addLayer() call — each of which re-clusters the
whole set. On a map with a few hundred pins that meant several full
teardown/rebuild passes per load, all synchronously on the main thread.
- Skip the rebuild when the marker signature (ids + weather colours +
layer toggles) is unchanged. Addressable events get a new id on every
edit, so ids reliably detect real changes; unrelated array churn
(trust scores, comment/zap totals, re-sorts) no longer rebuilds the map.
- Collect markers and add them with the plugin's bulk addLayers() in
requestAnimationFrame batches of 150, cancelling on effect re-run. One
cluster refresh per batch, pins paint progressively, UI stays responsive.
Adds components/SupportSkateSpots — two honest paths to send sats to the
project, reusing existing rails (no new deps, no backend):
- Zap SkateSpots: NIP-57 zap to the platform pubkey (public + optional comment,
needs a signer).
- Pay quietly: plain LNURL invoice from the platform lightning address
(anonymous, works logged out).
Payment runs NWC -> WebLN -> QR fallback, exactly like the Pro flow. Copy is
explicit that this is support, not a purchase (buys nothing, no refunds), and
that merch/Pro are separate.
1) Proximity intent: 'closest / near me / nearby' + spots is handled ON DEVICE —
the fix is read locally (fresh, else cached) purely to sort distances, and
nothing is transmitted (not to relays, not to the AI). Answers the nearest 3
with distances and says location never left the device.
2) System-prompt guardrail: the AI CANNOT see the user's location — for
closest/near-me it now says so and points at the map's Near Me (or asks for a
city), instead of guessing.
3) Intent hijack fix: a question mentioning both a crew and a spot was routed to
find_crews ('crews' was checked before 'spots'). Spots now win when both
appear.
- Header: name + Pro badge on line 1; context title + (owner-only) AI balance
on line 2. The live/CVM chip and AI balance no longer fight for the same row
and wrap.
- Footer: the live-status chip now sits with 'Live data from Nostr relays' and
'clear' instead of the header.
- Input: taller (h-10) with text-sm on mobile; send button matches.
- Message bubbles: text-xs and roomier padding/radius.
- lib/badges.ts + ProfileBadges: a profile shows (1) the built-in SkateSpots
badge, derived from a nip05 ending @skate-spots.org (app logo linking to
https://skate-spots.org/ — no storage, works for existing users), and (2) user
badges from a kind-0 badges array ({label,url?}) rendered as clickable chips.
- NIP19Page: app badge in the header, user badges in a 'Badges' card (replaces
the plain Sponsors section).
- EditProfileForm: Sponsors editor becomes a Badges editor (label + optional
link); saves the badges array and retires the legacy newline sponsors field.
Existing sponsors are read back as label-only badges, so nothing disappears.
- #3 usePersonalContext decrypts the Stash ON DEMAND (unlockStash, called by
getMyStash) instead of never — 'what's in my stash?' is now correct, while the
globally-mounted guide still never decrypts on mount. stashRef avoids the
stale-closure bug.
- #4 new self-contained action draft wtb_gear (NIP-99 listing_type=wtb) — a
'wanted' gear ad. SYSTEM_PROMPT + tool handler + publish branch.
- #5 panel header shows 'Pro · Nd' days left (title = exact expiry).
- #6 tier picker computes live USD via useExchangeRate; removed the hardcoded
~$60k/BTC usd field from PRO_TIERS.
- #7 copy: 'skate status' not 'spot status'; trip planning stated as free.
#1 Extend now actually extends. useSasquatchPro collected receipts and took the
max of (receipt.created_at + tierDays), so buying again before expiry
under-delivered. It now stacks every valid proof with renewal semantics
(expiry = max(expiry, proofTime) + days), client and server alike.
#2 Owner-grant fallback. Pro can now also be granted by an owner-authored NIP-32
kind-1985 label (org.skatespots.pro, p = user, expiry) — so Pro is not solely
dependent on the Lightning provider publishing zap receipts. New owner-only
GrantProCard (Settings -> Advanced) publishes it to getWriteRelays(); the hook
reads via querySkateContent.
Protocol: the x-sasquatch-pro payload is now {receipts,grants}; BOTH ai-proxy and
the Netlify function accept the new payload and a bare receipt (old APKs). Proxy
was deployed first and verified healthy before this commit.
Removed from App.tsx defaultConfig, NostrSync NEW_USER_RELAYS, NostrProvider
DEFAULT_RELAYS, TestApp, and the CVM DEPLOY.md example. It has been failing for
a while and just adds a dead socket + latency to every default relay set.
Existing accounts keep it in their stored config until they remove it in
Settings -> Relays (no migration applied). APK rebuilt (1.5.5 / 10505) so the
release matches.
Map tiles: new lib/tileCache.ts (Dexie SkateSpotsTilesDB, bounded to ~3000
tiles) and lib/cachedTileLayer.ts, a Leaflet TileLayer that reads through it
(cached -> fetch+cache -> plain URL fallback). Wired into both maps (SpotMap,
RouteDialog). Chosen over a service worker so it works in the Capacitor
WebView too, where a SW never sees tile requests.
Photos: useUploadFile drops from three Blossom hosts to two and records
['fallback', '<mirror>/<sha256>']. BlossomUploader signs once and Promise.any
PUTs to every server in parallel, so the old three-host list was triple-
uploading every photo (the 'tries them in order' comment was wrong). Read
side: lib/imageFallback.ts derives same-hash mirrors and ImageWithFallback
retries them before showing the placeholder - one choke point for ~20 photo
call sites, so a dead host no longer blanks a spot.
SkateSpots now runs its own NIP-05 registry, so a new skater gets a human
handle from minute one instead of an npub.
- netlify/functions/nip05.mjs: /.well-known/nostr.json plus
/api/nip05/{check,claim,release}, backed by Netlify Blobs (first-party,
no new vendor). Claims are NIP-98 signed and written onlyIfNew, so only
the owner can claim a name for themselves. Platform names are seeded in
code and cannot be taken or released.
- src/lib/skateNip05.ts + src/hooks/useSkateNip05.ts +
src/components/SkateNip05Input.tsx: shared validation, debounced
availability, and the claim control.
- SignupDialog: a 'SkateSpots name' field, prefilled from the display
name. Claimed best-effort before the profile publish; a failure never
blocks signup, and the profile only advertises nip05 once confirmed.
- EditProfileForm: claim a free handle from the NIP-05 field.
Verified on a draft deploy: claim -> resolves -> a second key gets 409 ->
release -> gone. tsc/eslint/101 tests green.
- Report INBOX: /review now defaults to a Reports tab. useRecentReports fetches
recent NIP-56 (1984) and resolves the referenced event by id (one extra ids:
query), so ANY report is actionable — content that isn't flagged, comments,
profiles, anything. reportTargetMeta maps kind -> label + route for the link.
Actions: Hide / Dismiss report (verify) / Undo / Look at it.
- Bulk undo: when a tab holds >1 verdict, a toolbar offers 'Undo all N' with an
AlertDialog that warns each undo is its own label publish (one Amber approval
each). Built for the 13 old hidden labels from Sep 3.
- Crews + Lab tabs: added includeHidden to useCrews/useLabProfiles. Hiding these
removes a community / a person's listing, so their Hide goes through a
confirmation (confirmHide/confirmHideNote on QueueList).
Verified in the browser: 8 tabs render; the inbox shows and resolves a real
report (target + reason); the Spots tab shows the bulk-undo toolbar.
tsc/eslint clean, 101 tests, smoke PASS.
- MODERATION_LABEL_VERIFIED: the positive counterpart to 'hidden'. Both labels
are fetched in ONE query; useAdminModeration now exposes verifiedCoords/
verifiedIds/isVerified() with a local mirror (addLocalConfirmed/
getLocalConfirmed). useAdminVerify() publishes it (shares the publish path
with useAdminHide).
- Review queue: a human review can now CONFIRM a spot, not just hide it. A
confirmed spot clears from the queue and gets a 'Verified by SkateSpots' badge
on the spot page (staff verdict, shown separately from the community badge).
- Two more signals: mapper trust (useBatchTrustScores) and whether any skater has
filed a status report (useBatchSpotStatus reporterCount) -> 'mapper not in your
web of trust' / 'no community signals'. The latter only fires alongside another
flag so a brand-new real spot isn't punished for being new.
- Flag logic moved to src/lib/spotQuality.ts so the page and AdminReviewCard share it.
Verified: the queue lists the airport entry with suspicious name + no photo + no
community signals, shows mapper trust, and offers Confirm/Hide/Not a skate
spot/Look at it.
The 'test of secret island airport' problem: a junk pin wearing an app-endorsed
'Skateability 95/100'.
- Rename the weather score to 'Conditions · weather only' (SpotDetailPage +
the SpotMap popup). It was never a verdict on the place.
- New Community verified / Unverified badge from signals only OTHER skaters can
produce: community photos + useSpotStatus reporterCount (kind 2259).
- CreateSpotPage now REQUIRES at least one photo (guard + disabled submit +
a required hint on the label).
- useNotASpotReport: one-tap NIP-56 (1984) report tagged 'not-a-skate-spot'
(NIP-56 'other' + our L/l/t taxonomy), wired into the spot's More menu and
added to the shared ReportDialog reasons.
- useAdminHide: the MISSING WRITE SIDE for admin moderation. useAdminModeration
always read NIP-32 kind-1985 'hidden' labels but nothing published one, so the
only working hide was device-local. Now publishes the label (+ optimistic
local hide). Soft and reversible — the event stays on relays.
- /review (AdminReviewPage, admin-only → 404 otherwise): review queue over
useSkateSpots with cheap flags (suspicious title, name <=3 chars, no photo, no
description, no geohash), sorted by flag count, one-tap Hide or Not a skate
spot. Entry via the admin-only AdminReviewCard in Settings → Advanced.
- /review added to the smoke-test route list.
Verified: the airport spot shows UNVERIFIED with 'Conditions · weather only' and
no 'Skateability'; the review queue lists it with flags + Hide/report actions;
create-spot blocks without a photo.
issueToMarkdown wraps each issue's vibe in _underscores_, but the in-app
markdown renderer only understood *asterisk* emphasis — so every published
archive issue showed LITERAL underscores around its intro line (both in our
reader and here). Added the underscore form to the tokenizer.
Verified on the live issue-5 naddr: the vibe now renders as <em> with no
literal underscores.
Root cause: AppProvider validates the persisted config with
AppConfigSchema.partial().parse(), and RelayMetadataSchema only declared
'relays' + 'updatedAt'. Zod drops unknown keys by default, so dmRelays (and
localRelays + blockedRelays) were SILENTLY STRIPPED on every page load. The DM
inbox relays (kind 10050) are usually not in a user's NIP-65 list, so DM reads
and writes fell back to NIP-65 relays - where the other side isn't listening -
and messages looked like they never went through.
- Declare localRelays/blockedRelays/dmRelays in RelayMetadataSchema.
- Send: publish gift wraps in PARALLEL with per-relay timeouts and resolve on
the first relay that accepts (a dead relay or auth.nostr1.com no longer
stalls the send); deliver to the recipient's NIP-65 WRITE relays when they
have no discoverable 10050 (the old fallback was dead code because our own DM
relays are always non-empty); discovery of 10050/10002 now runs in parallel.
- Read: query all DM relays in parallel with per-relay timeouts. Previously one
slow/dead relay burned the shared 15s budget and then the fired abort made
every remaining relay fail instantly.
Verified with an nsec login + a hostile relay list (dead + auth-required):
before, the wrap reached only write relays and the recipient found 0; after, it
lands on the configured inbox relays and both the recipient and our own copy are
found. Send worst case 20s -> 4.5s.
- Map is now the hero: h-[38vh] mobile / 400px desktop (was 256), with a metric
Leaflet scale control.
- New layout: flex column with header/modes/From/map pinned, the details as the
ONLY scroll region, and Start/Stop pinned at the bottom so it's always
reachable. Previously the dialog was a single overflow-hidden box, so once the
map grew everything below the fold (Start, steps) was unreachable.
- Summary rebuilt as stat tiles (distance / time / arrive) plus a detail line:
step count, honest 'uses driving roads'/'driving estimate' notes, and the
average speed the engine assumed ('21 km/h avg'; foot keeps the skate cruise
estimate). Steps header shows the count; step list scrolls taller (max-h-72).
- Wider dialog on desktop (sm:max-w-xl).
Verified in-app: tiles + 9/8/5/5 steps per mode, follow banner still tracks
movement, offline chip still appears, map 371px -> 321px with the new layout.
Follow mode (explicit Start; off by default — a watcher drains battery):
watchPosition in geolocation.ts, live dot, 'NEXT · step N' banner with
distance-to-turn + distance-to-go, current step highlighted, map follows.
A transient watch error (timeout/unavailable) no longer kills it — only a
permission denial does; the watcher recovers on its own.
Choose your start: 'From → My location / Choose on map' — tapping the map sets
the origin, and the route recomputes from there.
Offline: src/lib/routeCache.ts keeps the last route per destination+mode with
the line downsampled to <=250 points. If a fetch fails (no signal / routing
server down) the dialog falls back to it with an 'Offline · saved route {age}'
badge instead of a dead error.
Also tidied the map overlays (offline badge bottom-left, Whole route
bottom-right) so nothing covers Leaflet's zoom control.
Verified with CDP: controls present; follow banner tracks movement (step 1 ->
step 7, distances update); blocking routing shows the cached route + badge;
picking a start on the map changed the route (968 m -> 922 m). Still no voice
guidance or re-routing if you leave the route (documented).
Answering 'are we giving the best navigation experience?': no, it was a preview
with dead ends. Fixes the concrete gaps:
- ETA: 'arrive ~08:10 PM' alongside the duration.
- Mode memory: the last travel mode is remembered (skatespots:route-mode), so
cyclists/drivers aren't reset to Skate/Walk every trip.
- Tappable steps: each step pans the map and highlights; added a 'Whole route'
re-fit button to zoom back out.
- Taller route map on desktop (h-64 sm:h-80).
- min-h-0 on step rows: the app-wide 44px mobile touch-target rule was making
every step a 44px slab (rows now 29px).
Still out of scope (documented, not faked): live turn-by-turn following, voice
guidance and offline routing.
AMBER — 'pops on every screen':
staleTime: Infinity only stops REFETCHING, never the FIRST decrypt. So the
encrypted useSyncedAppData blobs (read-state, preferences) each ran a queryFn on
mount (racing consumers multiplied it) and relay responses landing during
navigation made it look per-page. useSyncedAppData now has interactiveNetwork
(default false): for an interactive signer the blob is LOCAL-ONLY — query not
even enabled, patch() a no-op — no decrypt on mount, no publish on a nav-tab
change. Verified with a mocked x-amber login: network-on => Amber op at startup,
with the fix => 0. (useCrewDismissals/useStash were already gated.)
ROUTING — 'same time for every transport option':
router.project-osrm.org hosts ONE car dataset and ignores the profile in the URL,
so foot/bike/car returned identical numbers. Switched to the FOSSGIS per-profile
servers (routing.openstreetmap.de/routed-{foot,bike,car}); the demo server is now
only a last-resort fallback and its result is flagged approximate ('driving
estimate'). Request steps=true and surface turn-by-turn steps for orientation,
plus a '~X min on a board' skate estimate next to the walking time. Verified:
same trip => walk 13 min / bike 5 / moto+car 2, with 5-9 steps each.
Audit of the newsletter section found two real bugs.
1) /newsletter rendered in an infinite loop (100% CPU, React 'Maximum update
depth exceeded'). Chain: useSyncedAppData returned a fresh object each render
-> useReadState.apply (dep: synced) changed identity -> markNewsletterRead /
markAllRead changed every render -> the page effect (deps include it) re-ran
after every render -> the mark set a NEW state object -> loop. The
notification bell looped the same way while open.
- useLocalStorage: setter is now referentially stable (ref + functional set).
- useSyncedAppData: returned object is memoized.
- useReadState: apply reads synced/interactive from refs (stable); marks are
idempotent (same object when already marked) so React bails.
- NewsletterPage marks read once per visit (mount-once ref guard).
2) Issues + subscription markers were published via the default pool (the
author's own NIP-65 relays) but READ via useSkateRelays (community relay +
reader's relays; logged-out = community relay only), so an admin without
goskatespots in their list would publish drops nobody could see. Composer,
backfill panel and the subscribe hook now publish to getWriteRelays() and
fail loudly if every relay rejects (useSpotChat pattern). NIP.md untouched
(no schema change).
Also: guard the overlay pill/reminder against an empty archive, and drop a
redundant canPublish clause.
- Desktop: the nav's overflow-x-auto clips its trailing item when the nav grows,
which is exactly the 'More' menu. Widened the header (max-w-[1700px] vs the
1400px page container), tightened nav padding to px-3 and nowrap. 'More' is
fully visible at 1280/1440/2000px with Marketplace pinned (was missing).
- Mobile: the burger menu hard-coded only Map/Lab/Events/Crews + a few extras, so
Marketplace (and Mints) were unreachable. It now appends every nav-catalog item
not already listed, giving mobile/desktop parity (Marketplace, Mints, …).
RouteDialog was effectively broken — users got an empty dialog / 'no route'.
- Map init: the Leaflet map lives in Radix Dialog content, which mounts AFTER
the first effect pass, so the mapDivRef guard was null, the init effect bailed,
and the map was never created (no route line, ever). Hold the node in state
with a callback ref and add it to the effect deps.
- Height: the global .leaflet-container{height:100%} override beats h-64; against
a height-less parent it resolved to 0 (invisible map). Height now lives on the
wrapper (relative h-64) and the map is h-full.
- Stacking: from the map, Directions rendered the dialog inside the spot Sheet
(two nested Radix modals; the sheet covered the route). The map now owns the
RouteDialog at page level and closes the sheet first.
- Routing transport: call OSRM directly (it sends CORS '*') with the cors proxy
only as fallback; distinguish busy / no-route / unreachable errors and guard
against NaN coordinates.
- Header: shrink-0 logo + whitespace-nowrap + gap, nav shrinks/scrolls so it
can never collide with (or glow over) the SkateSpots wordmark; tighter item
padding. Fixes the top-left logo/nav overlap.
- Amber: useReadState keeps read markers LOCAL-ONLY for interactive signers, so
merely opening the bell/DM no longer publishes (encrypt + sign = 2 popups).
- Reputation: lead with verifiedCounterparties (distinct people), cap each
partner at 3 in the WoT-weighted score, warn when every confirmation is with
one account. NIP.md documents the collusion limit honestly.
- Directions: skip the live GPS call when the cached fix is <2 min old; the
blocking 'Finding your location' overlay only shows with no seed (silent
refine chip otherwise). Near Me also flips the sort to proximity.
- .gitignore: cvm-server/package.json (local dev copy of package.cvm.json).
- Reviews: confirming a trade now lets the reviewer set a 1-10 skateboard-deck
rating, stored as ["rating","n"] on the kind-31871 attestation (NIP.md updated).
The Reputation tab shows each review's decks plus an average.
- New DeckRating component (self-drawn deck glyph) and TradeConfirmRow (rating
picker + confirm), used for both seller-confirm and buyer-confirm in My Trades.
- New 'Selling' tab: your marketplace listings (active/sold) with one-tap
Mark sold.
- /marketplace is now a hub with a sticky tab bar: Browse (the gear grid),
My Trades (existing flow), and Reputation — the trader's reputation score,
peer recommendations, and a Reviews/trade-records list built from kind-31871
attestations (who, sold/bought, method, amount, note, link to the listing).
- Nav: the old 'trades' entry becomes 'Marketplace' -> /marketplace (no longer
auth-only, since browsing is public).
- /trades and /listing/... deep links keep working.
- Nav: MIDDLE_SLOTS 3->4 and add 'mints' to the default middle + desktop tabs,
so the Cashu mints directory is reachable on the phone out of the box (also
still linked from Wallet; any of the 4 slots can be swapped via long-press).
- Directions: RouteDialog now seeds the origin from the last known location and
draws immediately, then refines with a live fix — no more hanging on 'Finding
your location…'. Shortened the web geolocation fallback 15s -> 8s.
- Amber: /api/ai/status is now public (it only reports a small balance), so
showing the AI balance no longer signs NIP-98 / opens the signer. The signer is
reserved for actions that actually need it.
Owner-only chip (next to the Pro/CVM chips) showing the upstream sats balance,
amber when low. useSasquatchAiStatus gains an 'enabled' option; the panel only
fetches while it is open, so the NIP-98 signature never fires at app startup.
- sasquatchBrain: PROXY_MODELS now match the Routstr node's slugs
(gemini-3-6-flash, z-ai-glm-5-3, gemini-3-7-flash).
- ai.mjs: new owner-only POST /api/ai/status (NIP-98 + owner pubkey) returning the
upstream balance + a 'low' flag (<500 sats); config.path now serves both routes.
- useSasquatchAiStatus + SasquatchAiStatusCard (owner-only, in Settings): shows the
balance and warns when low — the top-up nudge, no webhook/notification key needed.
- netlify/functions/ai.mjs: optional Pro enforcement (SASQUATCH_REQUIRE_PRO=1).
Requires a NIP-98 kind-27235 auth header (x-sasquatch-auth) and, unless the
caller is a platform owner, an active Pro pass (x-sasquatch-pro = a
provider-authored zap receipt) verified OFFLINE — signature, user binding,
platform tag, and paid-amount → tier. Off by default.
- src/lib/proAuth.ts: signs the NIP-98 once per session (no Amber popup per
message) and attaches the Pro receipt.
- sasquatchBrain: threads auth headers through the proxy (streaming + non).
- useSasquatchPro: exposes the winning receipt event.
- Verified: 200 with the gate off, 401 with it on (env reset afterwards).
The pay-to address was duplicated (PRO_LIGHTNING_ADDRESS and
PLATFORM_LIGHTNING_ADDRESS) and hardcoded in the tour zap-split; a typo in any
copy would send money nowhere. Now one source of truth in src/lib/platform.ts,
re-exported by the fee/Pro modules.
Also surface the address in the Sasquatch Pro dialog ('Pays to ⚡ <address>') so
users can see where their sats go, in both the auto-pay and manual-QR paths.
- useSasquatchPro: derive Pro expiry from what was ACTUALLY paid (largest tier
the bolt11 covers, run from the receipt time), instead of trusting the
user-signed `pro-expiry` tag — a 7-day payment could previously self-assert a
year. Provider-authored receipt + amount checks unchanged.
- SasquatchGuide: AI answers are now Pro-only. Free users get the static FAQ,
glossary, trip planner and local relay lookups (all no-cost), plus a nudge to
Pro when a question would otherwise need AI.
- AGENTS.md updated.
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
<Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
fragments instead of <Layout> (structure-preserving), imports dropped.
Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
- MapPage: replace the external 'Open with' menu (Google/OSM/Waze) with the
shared in-app RouteDialog, so the map spot sheet routes natively (incl. the
Transitous transit mode).
- FoodPage: replace the Google Maps handoff with RouteDialog.
- RouteDialog: remove the opt-in external 'Open in' handoff and delete
src/lib/directions.ts — no external map apps anywhere (product decision).
- sasquatchBrain: new streamSasquatchAI — OpenAI-style SSE via OpenRouter (if
the key is set) then Pollinations; falls back to the non-streaming caller only
when nothing at all was delivered, so blocked-stream environments still answer.
- SasquatchGuide: stream the first pass into a live bubble (raw JSON tool calls
are suppressed so partial JSON never shows; prose-then-tool output is dropped),
finalise the bubble in place, show a blinking cursor, and hide the generic
'thinking...' bubble while a reply streams.
- Drop Pollinations 'mistral' (now a 404) leaving the live 'openai' slug.
- routing.ts: add a 'transit' travel mode via Transitous (MOTIS 2 API, keyless,
open CORS), with a local precision-7 polyline decoder and per-leg parsing.
Road modes stay on keyless OSRM; moto remains ≈car and honestly labelled.
- RouteDialog: transit draws each leg (walk dashed grey, transit coloured by
mode) and summarises depart→arrive, duration, transfers and line chips, with
Transitous/OpenStreetMap attribution.
- directions.ts: opt-in 'Open in' handoff to Google / Apple / OpenStreetMap
(+ Organic Maps via geo: on Android), mode-aware. In-app routing stays the
default; the description notes transit shares the trip with Transitous.
- No scooter/moped profile (deliberate).
- sasquatchBrain: abort each provider request after 15s and give up the whole
multi-provider attempt after 35s, so a hung OpenRouter/Pollinations call can no
longer wedge the chat (it now falls through to the static/local fallbacks).
- SasquatchGuide: send the system prompt as a real system message (both the
first pass and the tool-synthesis pass) instead of smuggling it into a user
turn, and mark tool output as untrusted — blunts prompt injection via event
content.
- useCVMClient: signature-verify (verifyEvent) every relay result, matching the
standalone server and the release-notes claim.
- useCVMServerHealth: stale threshold 1h -> 45m to match the server heartbeat.
'Shops & Spots' wrapped to two lines and pushed its icon out of line with the
rest of the bar. Shorten nav.map to 'Spots' (en/es) and render every tab label
single-line (w-full truncate text-center), so long customised labels can't wrap
and misalign the bar either.
- Lab forms: a non-empty email must be valid and website/portfolio must
resolve to an https URL; invalid input shows an inline hint and blocks
publish. Adds isValidEmail() (reused by sanitizeEmail). social/instagram
are exempt since they may be bare @handles.
- New PhotoDropzone component: drag & drop plus multi-select image input,
emitting File[] to the caller. Wired into CreateSpotPage + EditSpotPage
(drop several photos at once) and AddPhotoToSpot, replacing the old
one-file-at-a-time inputs.
- Spot photo uploads run sequentially and dedupe URLs.
- Preserve the geo 'g' tag and paid 'featured' tag when editing any Lab
profile (all 7 craft roles via CraftProfileForm + 5 bespoke pages).
- Render previously collected-but-hidden fields on the Lab profile page:
judge 'events_judged', organizer 'events_hosted', editor hardware/software,
and spot references.
- Include Event Organizer (35013) in Community Stats + counts.
- Cap Title inputs at 100 (was 120) so profiles no longer fail the parser
and silently vanish; MIN_TITLE_LEN 2 -> 1 for 1-char display names.
- Normalize bare-domain URLs (example.com) to https:// before storing instead
of dropping/hiding them (new normalizeUrlInput helper; fixes extra url fields).
- No crew strip for judging/organizing (was wrongly showing Photo & Film);
scrollable Offer Services FAB; live bio counters.
Finishes the Lab audit: the 5 bespoke 'become' pages now prefill from your
existing profile (edit, not blind overwrite) and expose a 'Headline' field used
as the profile title. Also:
- submit button shows Update... when editing; success toast says updated
- removed the dead ExistingLabProfileNotice (the warning it gave is now moot)
Every one of the 12 Lab provider flows (5 bespoke + 7 craft) now edits + titles
consistently. Gate: 101 tests, 0 errors/warnings, boot smoke green.
Audit of the Lab 'offer a service' flows found two cross-cutting gaps:
1. Every Lab card showed the name TWICE (the card renders a 'title' line, but
all forms wrote title = the kind-0 display name). The field is meant to be a
self-described headline.
2. Only the Skater flow could be edited; the 7 craft roles started blank and
silently overwrote the previous profile on submit.
Fixes (shared CraftProfileForm -> 7 roles: artist, graffiti, tattoo, trainer,
musician, judge, organizer):
- New useMyLabProfileEvent(kind, identifier) hook; the form prefills from your
existing profile and becomes an EDIT (button: Update Profile; toast: updated).
- New 'Name / headline' field -> the tag (defaults to your name).
- Lab ProfileCard only shows the title line when it differs from the name.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
- TradeRecommendationsCard on profiles: shows who recommends this trader
(kind 31873 'Attestor Recommendation') and lets a logged-in viewer recommend
them. New useTraderRecommendations hook.
- OfferDialog sends a best-effort NIP-17 DM to the seller when an offer is made;
ListingOffersPanel DMs the buyer when the seller accepts/declines.
- AGENTS.md notes 31873.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
Promotes 'Offer' from a prefilled DM to a real, queryable event:
- Buyer publishes a 30403 offer (d/a/p + optional amount + message)
- Seller sees incoming offers on the listing detail page and can Accept/Decline
(publishes a 30403 response referencing the offer)
- Buyer sees their offer's status and can Withdraw it
- New: useMarketplaceOffers (parse + hooks), OfferDialog, ListingOffersPanel
- NIP.md documents kind 30403; AGENTS.md updated
Gate: 101 tests, 0 errors/warnings, boot smoke green.
- LazyMount: mount a grid card only when it scrolls within 600px (skeleton-space
placeholder otherwise), so a long feed no longer mounts every card + its
hooks/DOM up front. Falls back to immediate render without IntersectionObserver.
- GearSwapItemCard: clicking the card now opens /listing/:pubkey/:identifier
(was opening a DM). 'Contact Seller' still opens the DM. Card set to h-full so
the lazy wrapper keeps the equal-height grid.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
- Mints page: 'Online only' toggle + result count ('N of M mints')
- Wallet page: 'Browse Cashu mints' link to /mints
- ListingTradeActions now renders the EscrowPanel for a viewer's active escrow,
so escrow status shows on both the grid card and the detail page
- AGENTS.md project map: marketplace detail page + shared actions
Gate: 101 tests, 0 errors/warnings, boot smoke green.
So Local relays are used and Blocked relays are excluded EVERYWHERE, not just in
the NPool routers:
- useListingReports, useSellerInfo, useSkateRelays (read+write), useZaps
(NIP-57 relays tag), usePurchasePro, CollectionDetailPage, CrewForm,
useDirectMessages write fallback
- AppProvider's empty-relays defaults check is the only intentional direct reader
- AGENTS.md updated
Gate: 101 tests, 0 errors/warnings, boot smoke green.
- New '/listing/:pubkey/:identifier' page: large gallery + lightbox, full
description, details, seller trust + trade reputation card, and full actions
(shared) for buyers and owners (mark sold/relist/delete)
- Extract ListingTradeActions (confirm/escrow request/ask-to-confirm/refund/
contact + escrow dialog) and use it in BOTH the grid card and the detail page
so there is one implementation
- useMarketplaceItem(pubkey, identifier) fetch hook
- Cards link their image + title to the detail page
- Smoke test covers /listing/:pubkey/:identifier
Gate: 101 tests, 0 errors/warnings, boot smoke green.
The last 7 react-refresh warnings are dev-only HMR DX from intentional
component+hook/helper colocations (EventCountdown, SortDropdown, TrustFilter,
LanguageContext, NewsletterPage). Splitting them into new files is broad import
churn for no runtime benefit, so each carries a justified file-level disable.
npm test: 0 errors, 0 warnings, 101 tests; boot smoke green.
- RelayMetadata.dmRelays; Relays UI to add/remove, publishing kind 10050
- NostrSync fetches the user's 10050 on login (alongside 10002)
- DMs deliver gift wraps to recipients' + own DM relays (fallback: write relays)
and read from own DM relays + NIP-65 read relays
- useDmRelays() to look up others' DM relay lists
Also fixes a latent data-loss bug: NostrSync's 10002 fetch replaced
relayMetadata wholesale, which would wipe the new local/blocked/dm lists on
login — it now merges. 'Trusted' category intentionally skipped (no distinct
behaviour -> no empty UI); documented in AGENTS.md.
Add a purposeful subset of Amethyst-style relay categories (not a full clone):
- App-local 'Local relays' (never published; added to reads+writes)
- App-local 'Blocked relays' (excluded from every read/write)
- src/lib/relays.ts: resolveRelays()/useRelays() + URL normalisation
- NostrProvider req/event routers go through resolveRelays (blocklist applied)
- RelayListManager: Local/Blocked editors; NIP-65 save now preserves the new
lists instead of overwriting relayMetadata
Deliberately omitted (documented in AGENTS.md): KeyPackage (10051, no MLS
consumer) and proxy/broadcast/private-home (no such discovery model here).
Follow-up noted: a few hooks still read config.relayMetadata.relays directly.
Completes the escrow safety loop: a buyer whose escrow token is past its
locktime can reclaim it from the listing card ('Refund escrow'), which redeems
the P2PK token with their refund key. Native-only, same as the rest of escrow.
Completes the escrow feature on top of the tested core (src/lib/cashuEscrow.ts):
- useCashuEscrow: native-only hook wiring createEscrow (cashu-ts mint + NWC/WebLN
funding), redeemEscrow, escrow-key derivation (coco keyring) and profile publish
- EscrowKeyCard (Wallet): advertise the kind-0 'cashu_escrow' pubkey
- EscrowRequestDialog (listing card): buyer locks funds (P2PK to seller, refundable
after N days) and sends the 31872 carrying the cashu_token
- EscrowPanel + 'Redeem escrow' in the DM request banner (seller side); the panel
is offline/parse-based so it renders on web too
- Buyer 'Escrow & request' supersedes the plain request when available
EXPERIMENTAL: mint-touching paths are unverified on a device (native-only wallet);
use small amounts first. Gate: 101 tests, 0 errors.