The marketplace 'Sats Traded' was a naive sum of the self-reported amount tag on
31871, which two colluding accounts could inflate for free. tradedSats is now the
robust figure - mutual trades only (a listing attested by >=2 distinct pubkeys),
per-pair cap 3, amounts >1 BTC dropped - with tradedCount + tradedTraders, and
tradedSatsClaimed keeps the raw claim so the card shows both. Added query fields
=> PERSIST_BUSTER v5. No trade attestations exist on the relay yet (honest 0).
- 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.
Two issues from the first derived-regions pass:
- The 'Resolving N...' counter only updated when the whole pass finished, so it
looked frozen. fillDerivedRegions now reports progress per pin, the hook ticks
the counter down, and the card refreshes every 5 resolved pins so the numbers
climb while you watch.
- A country could appear twice ('United Kingdom' 1 + 1) when one pin carried a
country_code and another only a name. Learn name->code from pins that have
both and fold name-only pins into the same bucket.
The region card only placed pins carrying a city/country tag, so legacy pins
(coords but no tag) drew on the map yet landed in 'Uncategorised' — the map and
the stats disagreed. useGrowthStats now falls back to a cached reverse geocode
of the pin's own coordinates (lib/derivedRegions, localStorage keyed by ~110m
coords). useDeriveRegions (mounted on /stats) fills cache misses at 1.1s spacing
for Nominatim's 1 req/s policy and invalidates growth-stats; the card shows a
'Resolving N...' note meanwhile. Display-only fallback; the admin backfill still
writes real tags. Read-time geocoding disclosed on /privacy.
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.
A spot the community has hidden as 'not a skate spot' no longer inflates the
community numbers.
- useGrowthStats calls useAdminModeration() and drops hidden content inside
dedup(), so it's excluded from totalSpots, the region breakdown and the
trusted counts (kind-agnostic: the coord is built from each event's own kind).
- The hidden-set sizes are part of the query key, so the stats recompute once
the labels load.
- Region footnote says 'community relay only, with moderation-hidden content
excluded'.
This narrows the map<->stats gap. The remaining deltas are legitimate and
documented in AGENTS.md (untagged pins; auto-hidden spots).
Answers 'why don't the map and stats match' by making the gap visible, and
brings shops into the region breakdown.
- src/lib/geocodeRegions.ts is now the single reverse-geocode helper (spot
create, shop create, shop edit), so the Nominatim call + Accept-Language + the
parse rules can't drift. Never throws.
- Shops now publish city/country/country_code (previously none), so they can
appear in the region breakdown.
- Stats: regions cover spots AND shops; a new 'Uncategorised (N)' row plus a
'{N} of {M}' footnote let the card reconcile with the location total. Section
retitled 'Spots & Shops by Region'.
- PERSIST_BUSTER v3 -> v4.
- /privacy: wording now 'spot or shop geocoding'.
The map deliberately still reads the community relay + the reader's relays
(forcing canonical would hide an opted-out user's own spots) - documented.
Answers 'how are these accounted?' and fixes the privacy-page gap it exposed.
- Spot creation now also publishes ['country_code', <ISO-3166-1 alpha-2>], kept
alongside the display 'country' name so existing search/display still work.
- Stats group countries by country_code when present, else a lowercased name;
the label is the most common raw name in the group, so a localised spelling
can't split one country across two rows.
- The tail beyond the top 6 is surfaced as an 'Other' row, and the section is
relabelled 'Spots by Region' (it counts spots, not people) with a footnote.
- PERSIST_BUSTER v2 -> v3 (GrowthStats shape changed again).
- /privacy: disclose OpenStreetMap Nominatim — spot geocoding sees your IP and
the spot's pin (never the device's GPS). It was missing from the IP list.
Docs: AGENTS.md.
Three improvements to /stats.
1. A relay outage must not read as real zeros. Every sub-query swallowed its
error, so a failed relay rendered a full board of 0s. The queryFn now counts
failures and returns 'partial'; the page shows 'Couldn't reach every relay —
these numbers may be incomplete'. Badge also gains 'updated Nm ago' + Refresh.
2. Surface more of the community: Marketplace (live listings, completed trades,
self-reported sats, cash/barter split) and Regions (top countries/cities from
each spot's own tags). Zap receipts are counted ONLY for the top contributors
and labelled as not-global-volume, because global zap volume isn't measurable
on a public relay.
3. Mobile/desktop: collapse the 13 Lab cards on mobile behind 'Show all' (a
display:contents wrapper keeps them grid items); widen max-w-6xl -> max-w-7xl;
StatCard gains a compact prop so big sats don't overflow.
Docs: AGENTS.md community-stats note.
The stash is one replaceable NIP-44 blob, so a naive last-write-wins publish
silently dropped whatever another device added. Make it merge.
- StashItem.updatedAt (last edit, falls back to addedAt) and a top-level
Stash.deleted[] of {id, at} tombstones so deletes aren't resurrected.
- export mergeStashes(): union by id, newest stamp wins, tombstones win at/after
the last edit; tombstones pruned (180d / 500).
- fetchLatestStash() merges the live remote with the list already held (query
cache -> closure) and toasts when the other device contributed.
- Writes now require a confirmed relay read: assertConfirmed() refuses an
unconfirmed base so useNostrPublish can't queue a pre-signed blob that would
overwrite a newer edit later. Reads still work offline from cached ciphertext.
- StashPage surfaces the refusal via onStashError on every mutation.
Docs: NIP.md multi-device merge section; AGENTS.md updated.
The Wishlist tab lived only in the encrypted stash, so 'I want an 8.25 deck'
was invisible to sellers. A public want (itemType wtb) now publishes a real
NIP-99 listing with listing_type=wtb (tag stash-want, price = what they'd pay),
which the Marketplace already surfaces under its WTB filter; the encrypted
Wishlist keeps the private copy.
- buildListingTags handles the want case (no showcase tag, listing_type=wtb,
no condition); syncItem no longer skips wtb.
- StashPage drops the item.itemType !== 'wtb' showcase guards (add/edit/bulk).
- AddItemDialog defaults wants to PUBLIC (a want is meant to be seen) and the
visibility hint + wishlist teaser explain where it goes.
- Filter bar gains a Sort select (Newest / Value / Condition / A-Z) and search
now matches size too.
- 'Public stash' card lists which items are public with a link to your profile
('see what others see').
- Bulk actions: a header Select toggle puts rows/cards into multi-select
(checkbox + click-to-toggle, per-item actions hidden) with a fixed action bar
-- Public / Private / For sale / Remove -- backed by new bulkSetVisibility /
bulkSetForSale / bulkRemove mutations that each do ONE re-encrypt, plus a
bulk-delete confirm. Showcase sync/unpublish runs per affected item.
The private stash was deny-listed from persistence with no local cache, so an
offline or relay-outage cold start showed an EMPTY stash even though decrypting
needs no network.
- offlineCache v2 adds a 'stash' table storing the encrypted CIPHERTEXT (never
the decrypted list). useStash caches it on every successful fetch, and both
the read query and fetchLatestStash (the pre-write re-fetch) fall back to it --
so the stash loads at the park and an offline edit rebuilds from the real list
instead of throwing.
- /stash gains a 'Backup & transfer' card (desktop sidebar + mobile) with
Export (a JSON you control) and Import (one re-encrypt, skipping
title+category+size duplicates).
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.
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 directory we already fetch returns review ratings, reliability, uptime and
liveness-audit stats that the client was throwing away (it only typed ~12 of ~35
fields). Now:
- MintDirectoryEntry types the community fields (rating/weighted rating/count,
reliabilityScore, uptime 24h+7d, audit volume + recent errors, serverLocation,
units, mint/melt methods, pubkey, discoveredAt, lastError).
- Row shows the rating (e.g. 4.6 (30)) plus reliability and 24h uptime.
- Detail sheet shows rating, reliability, uptime, location, latency, NUTs,
units, version, plus a liveness-audit line and an honest note that written
reviews live on MintRadar (linked), not in the directory API.
Written comments are genuinely not in MintRadar's /api/mints/known (probed a few
plausible review endpoints - all 404), so the next step for in-app comments
would be Nostr-native mint reviews.
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.
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 'test of secret island airport' case: a no-photo, one-line, reported pin sat
on the map looking legit. Four fixes:
- A) Report has a visible effect: a 'Reported as not a skate spot by N — under
review' banner on the spot page, for everyone.
- B) Publish floor: a spot needs a photo OR a non-empty description. Photo stays
optional (per the earlier decision); title + type alone no longer publishes.
- C) Auto-hide from the map when >=3 DISTINCT accounts report it AND it has no
community signals (no community photo, no status report). The 'no signals'
clause is the anti-brigade guard; reversible. useSpotReports now exposes
distinct .
- D) The flattering 'Conditions ... NN/100' block is hidden until the spot is
confirmed (community or staff) — on an unconfirmed pin it read as the app
endorsing the spot itself.
- #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.
All four DM read hooks used config.relayMetadata.dmRelays, which is only set by
the login-time sync. When it is stale/empty the app reads only NIP-65 relays —
where gift wraps almost never are (real case: 94 addressed wraps on nostr.mom,
0-1 on the user's NIP-65 relays), so Messages showed 'No messages yet' with a
full inbox.
- useResolvedDmRelays(): discovers the user's own kind-10050 inbox relays at
query time (per-relay, 6s each, over read relays + aggregators) and unions
them in. Used by useConversation/useConversations/useGroupConversations/
useGroupConversation.
- useConversations now returns { conversations, undecryptable, wraps } and the
DM page surfaces 'N encrypted messages couldn't be opened', so a decrypt
failure stops looking identical to an empty inbox. Updated DirectMessagesPage
and ContactsPage.
Same proxy as netlify/functions/ai.mjs, rebuilt as a standalone Bun service on
the CVM host so AI calls stop spending Netlify compute/bandwidth credits and
the sats-paid Routstr key lives on our box.
- ai-proxy/server.ts: Bun.serve on 127.0.0.1:8787 — NIP-98 + Pro gate, model
allowlist, per-IP throttle, /api/ai + /api/ai/status. Own tsconfig
(typecheck:ai in npm test); eslint-ignored like cvm-server.
- ai-proxy/{package.json,.env.example,skatespots-ai.service,Caddyfile,DEPLOY.md}.
- scripts/deploy-ai-proxy.sh (npm run deploy:ai): scp + bun install + unit +
restart, mirroring deploy-cvm.sh.
- Client: one place decides the AI base, src/lib/sasquatchAi.ts (also the NIP-98
u tag). Defaults unchanged (/api/ai on Netlify); set VITE_SASQUATCH_AI_BASE to
move it. Fully reversible.
Deployed and verified on the host: /api/ai/status returns the live Routstr
balance. Public TLS (DNS + Caddy) is the remaining step before switching the app
over, so no client behaviour changes yet.
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.
Relay outages no longer look like 'no spots'. querySkateContent swallowed every
relay error and returned [], so a dead relay would blank the map AND overwrite
good cached data with an empty list (then persist it). Now:
- querySkateContent/queryCanonical take an opt-in 'requireAny' that throws when
NO relay answered. Only the map's own feeds use it, so the blast radius is
small (79 other call sites keep today's behaviour).
- useSkateSpots/useSkateSpot fall back to the local IndexedDB cache on failure,
so the map still works at the park; they only error when nothing is saved.
- The persister refuses to persist an empty array, so an outage can't wipe the
last-known map on the next cold start.
- MapPage shows an honest 'Offline · showing saved spots' banner (reusing the
existing relayProbe/useRelayStatus) and a 'Can't reach the relays' card
instead of falsely claiming 'No spots mapped yet'.
Also seeds skater@skate-spots.org for the second admin (caba497f…) and reserves
the name so nobody else can claim it.
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.
(a) Review queue covers spots/shops/events/tours/gear: added { includeHidden }
(admin-tooling only) to useSkateShops/useSkateEvents/useTours/
useMarketplaceItems; generalised the flags to contentFlags() in spotQuality.ts
(trust/status/photo signals stay spot-only); one component per kind so only the
OPEN tab queries. Reports + trust are kind-agnostic; the label mechanism never
cared about kind.
(b) Photo gate softened: removed the hard block (it punished an honest mapper with
no photo). Publish without a photo is allowed; the spot stays Unverified, the
success toast says so, and the queue's 'no photo' flag lets a human look.
(c) Empty-map nudge: MapPage now distinguishes a FILTER empty ('No spots match')
from a genuinely EMPTY map ('No spots mapped yet — be the first to map your
park', with Add-spot + Near-me). Note: must key off allLocations, not mapSpots
(which derives from filteredSpots).
Verified in the browser: the empty-map nudge renders with its CTAs when no spots
load; the review queue renders 15 spot rows and the tabs switch (Gear shows its
own empty state). tsc/eslint clean, 101 tests, smoke PASS.
1) Reports were a VOID: ReportDialog publishes NIP-56 (1984) but the only reader
in the app was a count on marketplace cards, so an admin never saw one. Now
useSpotReports batch-reads kind 1984 by '#e' (one query for every spot) and
the review queue ranks reported spots ABOVE the heuristics with a red
'REPORTED: ...' chip. useNotASpotReport publishes to getWriteRelays()
(community relay + the reporter's), not the default pool — the admin reads
from the community relay, so reports written only to personal relays would
never arrive. Verified end-to-end: published a report from Node, saw it
surface in the queue.
2) Duplicate guard in CreateSpotPage: on submit, existing spots within 60 m are
listed with distances + links, with 'It's a different spot — publish anyway'.
Targets the clone-spam failure mode (five near-identical pins).
3) Contributor feedback: useSkateSpot no longer nulls a hidden spot for its
AUTHOR (it used to silently 404 for the person who made it), and
SpotDetailPage shows an author-only banner for hidden/verified.
tsc/eslint clean, 101 tests, smoke PASS.
Undo (latest verdict wins):
- MODERATION_LABEL_CLEARED = 'cleared' — the neutral undo verdict.
- useAdminModeration no longer UNIONS labels (which made a hide permanent in the
UI); it keeps the NEWEST verdict per target (created_at, with device-local
verdicts overlaid by timestamp), so 'cleared' supersedes an earlier
'hidden'/'verified'. useAdminClear() publishes it.
- Local mirror is now a verdict map (skatespots_local_admin_verdicts_v1: key ->
{verdict, at}) with migration from the legacy hidden/confirmed arrays.
- useSkateSpots(limit, { includeHidden: true }) — ADMIN TOOLING ONLY: without it
a hidden spot leaves the list, so a verdict could never be undone. The review
queue uses it, keeps actioned rows (Hidden/Confirmed badge + Undo), and sorts
un-actioned first.
Community photos as a signal:
- useBatchSpotPhotos: ONE #a query for every spot (the single-spot hook already
queried by #a, so batching is free), counting only photos from people OTHER
than the mapper. Folded into the 'no community signals' flag.
Verified by seeding local verdicts: none -> flags + Confirm/Hide; hidden ->
Undo; verified -> Undo; cleared -> back to neutral. 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.
New /app-shop page for SkateSpots merch, to be paid in sats only (Lightning or
Cashu) — no cards, no accounts, no middleman.
Design: gradient/glow hero ('Wear the scene' + Coming soon badge), an honest
stock strip (0 items / None pre-orders / Nothing for sale today), an 'In the
works' teaser grid (tees, decks, stickers, tools — dashed + 'soon', no prices),
a three-step 'How it'll work' (pick -> pay in sats -> ships), a newsletter
notify band (the drop lands there first) and a plain-English honesty footer.
Deliberately contains no items: no stock, no prices, no pre-orders, no buy
buttons. AGENTS.md documents that rule so nobody invents a catalogue later.
Wiring: registered in NAV_CATALOG as id 'app-shop' (appears in the desktop
'More' menu AND the mobile burger menu automatically), lazy route in AppRouter,
and added to the smoke-test route list so a broken page aborts deploys.
Verified: page renders desktop + mobile (all sections), listed in both navs,
smoke PASS with /app-shop.
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.
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.
On the native app the shell is served from https://localhost, so the relative
/api/mints/known hit the local asset server and returned index.html, surfacing as
'Unexpected token < ... not valid JSON' on /mints.
- Native now fetches https://skate-spots.org/api/mints/known via CapacitorHttp
(bundled core plugin, no WebView CORS), with a plain-fetch fallback.
- Web/dev unchanged (same-origin relative path via the Netlify redirect / Vite
proxy).
- HTML or unparseable bodies now become a friendly 'unavailable' message instead
of a raw JSON parse error.
- 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.
useCrewAgentChat now signs the same NIP-98 auth + attaches the viewer's Pro
receipt (when they have one), so the proxy's Pro gate lets Pro users through the
gateway models; everyone else still falls back to the keyless provider.
- 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.
Root cause: Layout is NOT a router layout — ~62 pages wrap themselves in
<Layout>, so its chrome remounts on every route change. The header's
NotificationBell -> useNotificationsRead -> useReadState -> useSyncedAppData
re-decrypts an encrypted kind-30078 event on mount; with Amber (NIP-55) every
nip44.decrypt opens the signer app, so it popped on each menu click.
Fix: for interactive signers, decrypt once per session and serve remounts from
cache (staleTime/gcTime Infinity, refetchOnMount/refetchOnReconnect false) in
useSyncedAppData and useStash. Non-interactive signers refetch as before.
Follow-up (noted in AGENTS.md): hoist Layout into the router so the chrome mounts
once — removes the whole remount class (and SasquatchGuide state resets).
useCVMClient's findCoaches ROLE_KIND, self-tag set and getCommunityStats stopped
at 35012, so Sasquatch could not find the event-organizer (35013) or barber
(35014) profiles we added. Extend LabRole, ROLE_KIND, SELF_TAGS and the stats
queries/counts (lab_total) to cover all Lab kinds 35001-35014.
- 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.
- 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.
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.
- New '/trades' page: action-needed (incoming 'confirm this sale' requests as
seller, pending confirmations as buyer) with inline actions, a reputation
card, and the user's own trade records (role/counterparty/state) linking to
each listing. Nav entry (auth-only) + smoke route.
- DM inbox relays (kind 10050) are now block-filtered on both read and delivery
(closes the last relay caveat).
- AGENTS.md updated.
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.
- 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.
New '/mints' page: browse Community MintRadar mints with health (online/latency/
degraded/archived), NUT count, terms, and add-to-wallet on native.
MintRadar has no CORS, so it's fetched same-origin at /api/mints/*:
- prod: Netlify proxy redirect in public/_redirects (+ 10m cache in _headers)
- dev: Vite server.proxy in vite.config.ts
This also hides users' IPs from MintRadar. Data is unofficial/rate-limited;
the page degrades to a friendly error state. Nav entry + smoke route added.
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.
Marketplace:
- Buyer can 'Ask seller to confirm' a sold listing -> publishes a 31872
Attestation Request (d/a/p), which the seller fulfils in one tap
- Seller sees incoming requests in the DM thread ('Confirm this sale?') and the
fulfilling 31871 carries the spec's 'request' tag
- useTradeAttestationRequest / useIncomingTradeRequests / useListingTradeRequest
- NIP.md documents 31872 and states escrow is intentionally NOT implemented
(coco-cashu has no P2PK/HTLC locking, wallet is native-only, and release-on-
attestation needs a witness) so nothing looks trustless that isn't
Hardening:
- scripts/smoke-test.mjs navigated to /settings etc. WITHOUT a hash, so with
HashRouter it only ever loaded the homepage and reported PASS. It now puts the
route in the hash, covers settings/stash/collections/stats/marketplace, and
fails on the 'This page slammed' error boundary (not just boot failure)
- Reputation now exposes per-counterparty verified counts; the profile card
trust-weights each confirmed trade by the counterparty's web-of-trust score
(fresh-account rings can't inflate it) and labels Highly trusted / Building /
New trader
- usePendingTradeConfirmations finds sales a seller recorded that name you but
you haven't mirrored; the 1:1 DM thread shows a 'Did you complete this trade?'
banner with one-tap Confirm
Built on the Attestations NIP (31871); a listing is the Assertion Event:
- Seller 'Mark as sold' now opens a dialog (buyer npub + self-reported method +
optional amount) that marks the listing sold AND publishes a trade attestation
- Buyer sees 'Confirm this trade' on the listing and publishes the mirror
attestation -> mutual pair = a verified trade
- useTradeReputation aggregates sales/purchases/verified + counterparties
- Profile 'Trades' card; seller 'N verified trades' badge on listing cards
- NIP.md documents kind 31871 usage + our p/method/amount extensions
Payment method is self-reported metadata, never proof of payment.
The Wallet page was Cashu-only and said 'App only' on web, so anyone with an
NWC wallet connected saw nothing. Adds:
- useNWC.getBalance() (millisats -> sats) and makeInvoice() via NWCClient
- NwcWalletCard: live balance + refresh, Receive (create invoice, QR, copy),
Send (paste invoice, decoded amount shown, pay via the connected wallet)
Renders only when an NWC connection is active.
Uses the existing gift-wrap primitive (kind 14 rumor, one p-tag per recipient):
- useGroupConversations: lists multi-recipient threads, bucketed by the sorted
participant key; the inbox shows a Groups section
- GroupConversationPage (/messages/group/:participants): encrypted group thread
with sender names, send + error feedback
- smoke test now boots the new route
Group creation UI (multi-select in NewMessageDialog) is the next step. For
stronger guarantees (forward secrecy, rosters) Marmot/MLS or Cordn would be the
protocol to adopt later; noted in the hook.
- Layout (app shell) imported useCashu, pulling the Cashu stack into the entry
chunk every visitor downloads. The three balance widgets now live in
components/wallet/CashuBalance.tsx and load via React.lazy.
- useNWC statically imported @getalby/sdk; it is now a type-only import plus a
dynamic import at call sites, so the SDK only loads when NWC is used.
Entry chunk: 1879 KB -> 1835 KB.
New /leaderboards route (linked from the More menu): aggregates kind-9735
receipts into top-zapped spots and top supporters, with week/month/all-time
tabs. Reuses the batched zap helpers. Also hardens the deploy smoke test to
boot lazy routes (/, /leaderboards, /bounties), since route chunks can fail
independently.
SortDropdown offered 'Most Zapped' but MapPage's sort switch had no zaps case,
so picking it silently fell back to newest-first. Adds a batched useZapTotals
hook (ONE receipt query for every location) and implements the sort.
Adopt PosterChan's "encrypted events are the datastore" idea for two
per-browser bits of state, so they follow the user's npub across devices.
New shared engine (src/hooks/useSyncedAppData.ts):
- Stores one encrypted kind-30078 (NIP-78) event per d-tag, NIP-44
encrypt-to-self, modeled on the proven useStash pattern.
- Clobber-guard: re-fetch + re-decrypt the freshest remote copy and
MERGE the local patch in before publishing (never overwrites a field
another device just set).
- Debounced publisher (coalesces rapid writes, flushes on pagehide/
beforeunload) so frequent read-marker changes don't spam relays.
- No-op for anon / non-NIP-44 signers.
Read-state sync (src/hooks/useReadState.ts), d-tag skatespots:read-state:
- Unifies the four previously localStorage-only markers (notification
bell, DMs, crew chat, newsletter) into one synced blob.
- Merge = field-wise MAX (markers only move forward → commutative, safe).
- The four legacy hooks now re-export drop-in replacements with identical
signatures, so NO consumer component changed. localStorage stays as the
instant/offline/anon fast path; the relay copy hydrates and max-merges.
- Fixes the real bug where clearing a badge on one device left it lit on
another.
Preference sync (src/hooks/usePreferences.ts), d-tag skatespots:preferences:
- Mobile + desktop nav layouts now sync (language field wired for later).
- Merge = last-write-wins by updatedAt (a pref replaces, not accumulates).
- useNavPreferences / useDesktopNavPreferences adopt a synced layout when
present and mirror changes; public APIs unchanged, localStorage fallback
intact.
Docs: NIP.md gains a "Cross-device app data (kind 30078, encrypted)"
section documenting both d-tags, their content shapes, merge policies, and
the clobber-guard/debounce/anon safety notes.
Build + typecheck pass.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Two type errors (surfaced by `npm run test` / tsc --noEmit on a full
toolchain; the in-browser bundler does not run tsc, so they were latent):
- useCVMClient.ts: remove a malformed cast
`spots as typeof spots & { tags; created_at }[]` feeding
deduplicateByIdentity(). The intersection dropped pubkey/kind and broke
the reassignment to `spots`. deduplicateAddressable() already returns
the correct shape, so the cast was both wrong and unnecessary.
- queryPersister.ts: import PersistedClient / Persister from
@tanstack/query-persist-client-core (their canonical source) instead of
@tanstack/react-query-persist-client, whose `export *` re-export tsc
won't resolve for named type imports. Added the core package (5.102.8,
matching the pinned wrapper) as an explicit dependency.
Both are pre-existing issues on the branch, unrelated to the audit
remediation commits.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audit found the Pro paywall promised three things with no implementation:
- "Priority AI model, higher limits & longer memory" — Pro and free hit the
exact same model/limits/history (only difference is the personal-tools prompt)
- "Proactive nudges (weather + spot tips)" — no nudge/scheduler exists anywhere
- "Pro-only Sasquatch skins" — single hardcoded avatar; no skin system
- "Supporter badge" — gating exists but no badge is rendered
Rewrote all Pro copy to promise ONLY what actually ships (personal assistant
over the user's own Stash/crews/spots/listings, confirm-before-publish action
drafts, personal-context answers, and directly funding an ad-free app):
- SasquatchProDialog PERKS list (dropped Sparkles import now unused)
- usePurchasePro receipt DM
- 6 STATIC_FAQ Pro answers in SasquatchGuide
Pricing, payment target (skatespots@minibits.cash) and npub-bound zap-receipt
verification were already accurate and left unchanged. Build clean, no console
errors.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The old identity dedupe keyed on `name|place` where place was a single field
(geohash OR location OR city, first non-empty). Two copies of the same spot —
one carrying a geohash, one carrying only a city — produced different keys and
both survived (the Yellow Park duplicate).
Now:
- Name is reduced to a signature that strips trailing generic words
(skatepark/park/plaza/spot/diy/...) and leading articles, so "Yellow Park",
"Yellow Park Skatepark" and "Yellow Park " collapse to "yellow".
- Places are compared on human-readable tokens (location/city/country) only;
the opaque geohash is excluded. An empty place on either side is treated as a
wildcard, so a geohash-only copy still merges with a "Bangkok" copy.
- Genuinely different spots (different name signatures, or non-empty places with
no shared token) are still kept separate; single-word names are preserved so
generic names don't over-collapse.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The local fast-path was stricter than the AI it replaced: a typo like "bangok"
won't substring-match "Bangkok", so the fast-path returned a false "No spots
listed there yet" even though Bangkok has spots.
Two-layer fix:
1. Safety net (most important): if a finder fast-path comes back EMPTY, fall
through to the AI path instead of rendering "nothing found". The AI normalizes
spelling and can still find results. Stats/weather always render immediately.
Net effect: fast when the local match works, correct-always via AI when it
doesn't — never a false empty.
2. Typo tolerance: findSpots city matching now uses placeMatches() — substring
OR edit-distance-1 against candidate word tokens (only for queries ≥4 chars,
so short words can't over-match). So "bangok" matches "Bangkok" on the fast
path too. Freeform content stays a strict substring check to avoid false hits.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Two bugs from "spots in bangkok" (said "Found 20" but showed 6, with repeats):
1. Misleading count: formatLocalResults hard-capped the visible list to 6 while
the headline reported the full count. Now shows up to 10 and appends a
truthful "…and N more — see them all on the map" line, so the number and the
list always agree.
2. Duplicate spots: coordinate dedupe (kind:pubkey:d) can't catch the same
real-world spot re-posted under a different identifier or by another account
(e.g. "Yellow Park – Sukhumvit 50" appearing twice). Added
deduplicateByIdentity() — a second-pass dedupe on a normalized
name + geohash/location signature, keeping the freshest event — applied in
findSpots so the count and list are clean.
Both fixes flow through the AI tool-call path and the offline fallback path.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Three low-risk speedups focused on the slowest path — questions that hit live
relay data:
1. Relay timeout 10s → 4.5s (6s for the fan-out community stats). The useful
relays answer in ~2s; the old 10s just let one slow/dead relay stall the
whole reply.
2. Results-first: for finder tools (spots/events/coaches/gear/crews/shops/
tours/stats/weather) format the CVM result locally and SKIP the second
"prettify" AI call. Removes a full model round-trip (~2–4s) — users want the
list, not extra prose. New toolCallToIntent() maps a tool call to the existing
formatLocalResults() path. Personal/Pro tools (get_my_*) still use AI synthesis.
3. Faster AI fallback: cap OpenRouter attempts to the top 3 free models before
the keyless backup, and shorten 429 backoff (≤800ms vs ≤2500ms). Avoids the
worst-case 15–20s grind through 8 models on a busy free tier.
Conversational/knowledge answers (single AI call) keep Sasquatch's personality
and are untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Full self-audit of the Citrine feature. Fixes found:
Robustness
- Probe now tries BOTH ws://127.0.0.1 and ws://localhost and routes to whichever
actually answers (probeCitrineEndpoints + CitrineProbeResult.url). We no longer
bet the whole feature on one loopback address form; useCitrine.url reflects the
winning endpoint so reads/writes go to the right place.
Correctness
- Preview toggle was mutating the REAL setting while in dev-preview; handleToggle
is now a no-op under preview (forced state owns the visuals).
Accessibility / "flawless" bar
- Respect prefers-reduced-motion: status pips, chip pop-in and the expand slide
now motion-reduce:animate-none (no looping glow/pulse for reduced-motion users).
- Recheck button gets an aria-label; status text is role="status" aria-live so
screen readers announce searching→detected transitions; decorative pips are
aria-hidden.
UX copy
- "Waiting" state no longer presumes Citrine isn't installed. Clearer two-step
guidance (Open Citrine → Start; come back → Recheck) covering the common
"installed but not started/detected yet" case.
Honest note: MIXED_CONTENT_ALWAYS_ALLOW (1.3.5) is the high-probability fix for
the 0-connections block but is not device-verified from here; the dual-endpoint
probe hedges the second most likely cause (address form).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Citrine is running (listening on ws://127.0.0.1:4869) but the app can't connect
and shows 0 connections — a WebView-layer block, not our logic. To diagnose
without adb/USB, the probe now returns { ok, reason } capturing which stage
failed (blocked / connect-error / timeout-no-open / opened-but-no-nostr-reply /
closed-before-open with code), and the Settings card shows a small debug readout
(probe url + reason) whenever not connected. Purely additive; no behaviour
change to routing.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Self-audit of the 1.3.0–1.3.2 Citrine feature found two real issues:
1. False-positive detection (the important one). probeCitrine resolved true on
bare socket OPEN, so ANY process squatting on 127.0.0.1:4869 would be treated
as Citrine — and we'd then route reads AND published events at a non-Nostr
peer. The probe now completes a minimal Nostr handshake: open → send a bounded
REQ with a random sub id → require a protocol-shaped reply (EVENT/EOSE/CLOSED
echoing the sub id, or a NOTICE) before declaring the relay live. Non-JSON /
non-Nostr responses fall through to a clean timeout=false. A real relay
answers with EOSE in ~ms on localhost, so the happy path is unchanged.
2. Probe churn. The reachability query re-opened a fresh ws:// probe every 5 min
and on every window focus for the whole session — needless battery/wakelock,
and a localhost poke on every Amber round-trip. Now it only polls (30s) and
refetches-on-focus WHILE still waiting for Citrine to appear; once connected
it stops the background churn (a per-session local relay won't vanish
silently, and a failed read re-probes naturally).
Reviewed and left as-is (deliberate, safe): writing every kind to Citrine incl.
gift-wrapped DMs / Stash is intended on-device encrypted backup — all sensitive
kinds are NIP-44 encrypted before reaching the publish layer, so nothing
plaintext-sensitive is mirrored. Preview flag remains render-only.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The CitrineCard is Android-only (renders null off-native), so its four visual
states couldn't be reviewed without an APK build. Adds a strictly cosmetic
preview switch:
localStorage.setItem('citrine:preview', '1') // show card, real behaviour
localStorage.setItem('citrine:preview', 'live') // force connected state
localStorage.setItem('citrine:preview', 'waiting') // force not-detected
localStorage.setItem('citrine:preview', 'searching') // force searching
localStorage.removeItem('citrine:preview') // back to normal
Safety: the flag unlocks RENDERING ONLY via isCitrinePossible(). It never sets
citrine.active and never yields a relay url, so no query or publish is ever
routed anywhere different — production relay behaviour is identical with or
without the flag. A forced state is purely visual (enabled/probing are derived
locally in the card for preview; the real probe still governs active/url).
lib/citrine.ts: citrinePreview()/isCitrinePreview() + CitrinePreviewState.
useCitrine: exposes `preview`. CitrineCard: forced state overrides the computed
state for review only.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Completes the local-relay integration so it actually works end-to-end and feels
first-class.
Native (Android):
- android-templates/network_security_config.xml — permits cleartext for
LOOPBACK ONLY (127.0.0.1 / localhost / ::1); all real traffic stays TLS. This
is what lets ws://127.0.0.1 (Citrine) connect on API 28+, which otherwise
silently blocks cleartext.
- scripts/inject-network-security.mjs — idempotently copies the XML into
res/xml/ and adds android:networkSecurityConfig to <application>, mirroring
the existing inject-amber-queries pattern so it survives `cap sync`.
- Wired into android:init / android:sync / android:build.
Reads AND writes now reach Citrine app-wide:
- NostrProvider's NPool routers (reqRouter/eventRouter) fold in the active
Citrine URL via a ref, so every default-pool query hits it first (instant
local reads) and every useNostrPublish write also lands on-device (backup +
offline capture). Previously only explicit useSkateRelays paths benefited.
- A dedicated effect refreshes ['nostr'] queries when the local relay comes/goes
so reads switch over immediately. Fully no-op off-Android (url stays null).
UI/UX (Relays settings CitrineCard):
- Four crisp states — off / searching / live / waiting — with a breathing then
glowing status pip, benefit chips (Instant reads · Works offline · On-device
backup) that pop in on connect, a spinning Recheck, an actionable install CTA,
and a persistent encryption-reassurance note.
- Graceful first-run: shows "searching" until the first probe settles (via new
useCitrine.settled), never flashing the orange "not detected" state.
Tailwind: added status-breathe / status-live / pop-in keyframes+animations.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Integrates Citrine (github.com/greenart7c3/Citrine), an Android app that runs a
full Nostr relay locally on the phone. Because it's "just a relay URL", it folds
into the existing useSkateRelays / nostr.relay() machinery with no new protocol.
What it buys SkateSpots:
- Instant reads: a localhost hit (~1ms) is queried first, remote relays run in
parallel and reconcile — every skate-content surface feels faster.
- Offline-in-the-field: reads work with no signal; writes land on-device and
publish to remote when connectivity returns.
- Resilience/backup: a durable local copy of the user's own events survives
public-relay outages and addressable-event eviction.
- Privacy: local-first data can lean on the device instead of public relays.
Design / safety:
- Android-only (Capacitor native + platform === 'android'); renders nothing and
never probes localhost on iOS/desktop web. Mixed-content ws:// is already
permitted via capacitor allowMixedContent.
- Opt-in only via config.useCitrineRelay (default off). No data is ever routed
through Citrine unless the user enables it AND a relay is actually reachable.
- Reachability probe (short-lived WebSocket) cached through react-query, shared
app-wide, re-checked periodically + on focus, with a manual Recheck button.
- When active, the local URL is prepended to reads and included in writes; the
read-relay set feeds getQueryKeySuffix so caches refetch automatically when
Citrine comes/goes.
- Encryption unchanged: Stash/DMs stay NIP-44 encrypted regardless of
destination — the Settings card states this explicitly. Local ≠ plaintext.
New: src/lib/citrine.ts, src/hooks/useCitrine.ts, CitrineCard in the Relays
settings screen. AppConfig gains useCitrineRelay (zod-validated, optional).
On-device verification still needed: confirm Android permits ws://127.0.0.1
cleartext to localhost (network security config) once Citrine is running.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
NIP-17 group messages (rumor with >1 recipient p-tag) were bleeding into the
1:1 surfaces:
- useConversation (thread view): a group message from `otherPubkey` where I was
also a recipient satisfied isFromOther && isToMe, so group chatter appeared
inside the 1:1 thread.
- useConversations (list): group messages were collapsed to conversationWith =
sender (or an arbitrary recipient for my own sends), spawning phantom 1:1
entries and double-listing every group (once here, once in
useGroupConversations).
Both 1:1 hooks now skip any rumor with more than one p-tag. Groups remain the
sole responsibility of the useGroupConversation(s) hooks, which already require
pTags.length > 1. Genuine 1:1 rumors carry exactly one recipient p-tag
(createRumor doesn't tag the sender), so no legitimate message is dropped.
Verified solid (no changes): the seal(13)/rumor(14) kind checks, the
rumor.pubkey === seal.pubkey impersonation guard, and the amberSafeQueryOptions
decrypt-storm protection for interactive signers are all correct.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audited all three remaining surfaces. Two of them needed no changes:
MARKETPLACE / GEARSWAP — sound. useMarkListingSold preserves every tag
and flips only `status` (author-gated, right comment explaining why it
doesn't rebuild). Queries validate + dedupe, exclude showcase-only Stash
items from the sellable market, and GearSwap filters out expired/sold
listings and separates WTB from WTS. No bugs.
STASH — sound, and notably careful. The private list is one NIP-44
encrypted-to-self blob; all three mutations (add/update/remove) use
fetchLatestStash() (fetch-before-write) which falls back to non-empty
cache and THROWS rather than risk clobbering the user's gear, and is
Amber-gated (staleTime: Infinity). The public/private showcase
reconciliation in StashPage is complete: going public publishes the
kind-30402 showcase event, and going private / deleting / converting to
a wishlist item all call unpublishItem — so a public event can't linger
after an item is made private (no privacy leak). No bugs.
THE LAB — the collective join-request clobber was already fixed in
1.2.37. One robustness gap remained: useLabProfile fetched the profile
with limit:1 and trusted whatever came back. Relays can hold multiple
revisions of an addressable event, so a stale revision could win —
showing an old bio or, worse, a roster missing recently-approved
members. Now fetches a few (limit:10) and reduces to the newest
created_at, matching the pickMostRecent discipline already in useCrew.
On detail pages an author filter is always set, so newest-of-one-author
is exactly right.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>