Replaced the per-category emoji map (street/park/DIY/plaza/transition) with one
generic skateboard glyph on every spot pin, per request. The pin's crisp shape,
tight halo and shelter roof-cap (covered/indoor) are unchanged. Dropped the now-
unused `category` option from createSkateSpotIcon and its call sites.
Build clean; the earlier "CATEGORY_GLYPH is not defined" console errors were
from a stale intermediate bundle and do not occur in the current build.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Visual foundation for the map (ideas #2, #3, #4, #6 — features #5/#7 intentionally
deferred to a later pass):
#2 Crisp pins, not blobs:
- Reworked createSkateSpotIcon — solid neon teardrop with a TIGHT glow halo
(inset dark ring + focused shadow) instead of the soft box-shadow that read as
a fuzzy cloud on light tiles. Cluster badges given the same crisp treatment.
#3 Marker = spot type (a readable language):
- Category emoji inside each pin: 🛹 street / 🏞️ park / 🔧 DIY / 🏛️ plaza / 🌊 transition.
- Covered/indoor spots get a cyan roof "cap" so dry spots stand out at a glance —
the payoff of the shelter data living on the map, not just a badge.
- createSkateSpotIcon now takes an options object (weatherColor/weatherIcon/
category/shelter); all call sites updated. Weather-layer colouring unchanged.
#4 Less scrim, floating loading:
- Suspense map-load fallback dimming reduced (bg-black/50 → /20).
- "Finding spots" pill moved from dead-centre (top 46%) to a slim top pill so it
never blocks the map where the spots are.
#6 Micro-interactions (CSS, GPU-friendly, reduced-motion aware):
- New pins drop in with a spring; hover/focus/tap lifts the pin and pulses its
neon halo once.
Build clean, no console errors. No tile/theme change — the "og" light map is
untouched per request.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Skaters need to know if a spot is roofed/indoor so weather info isn't
misleading — a covered or indoor spot is still skateable in the rain. Added a
4-way shelter level: outdoor (default) / partial / covered / indoor.
Data model (types.ts + skateTags.ts):
- SkateSpot.shelter parsed from a `['shelter', value]` tag (validated to known
values; absent/unknown → treated as outdoor).
- shelterOptions / DEFAULT_SHELTER / SHELTERED_VALUES + ShelterType exported.
- SHELTER_INFO display map (emoji/label/short/color) for badges.
Publishing (CreateSpotPage + EditSpotPage):
- New "Shelter" selector reusing the exact selectable-Card pattern used by
Access Type / Spot Type (cn() borders, neon ring, corner check badge).
- Emits `['shelter', value]` always, plus a queryable `['t', value]` for
sheltered spots (indoor/covered/partial) so "dry spots" filtering + Sasquatch
search work at the relay level. Outdoor adds no t tag.
- EditSpotPage initialises from the existing spot and preserves the value.
Display:
- Shelter badge on SpotDetailPage, SpotCard, and the MapPage popup — only shown
for sheltered spots (outdoor stays unbadged to avoid clutter).
Docs:
- NIP.md: shelter added to the optional-tags table, a dedicated Shelter section
explaining values + the queryable-t-tag behaviour, and an indoor example.
Build clean, no console errors.
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 chat's data layer (useCVMClient) queries Nostr relays directly from the
browser — the CVM/ContextVM server round-trip was removed earlier for speed.
But the UI and system prompt still claimed data came "from the CVM server",
which is no longer true. Aligned every user-facing claim with reality:
- System prompt: reframed "CVM Tools / MUST route through CVM" as "Live data
tools that query the Nostr relays directly". Tool-calling behaviour unchanged.
- Streaming status: "Querying CVM for X" → "Searching the relays for X".
- Tool-failure message: no longer blames a non-existent "CVM server"; now
correctly frames it as relays being slow, with a retry nudge.
- Empty results: stop asserting "not added yet" as fact — an empty result can
also mean slow relays or a misspelled place. Now offers retry + add-a-spot,
and names the place searched. Applied to the local formatter, the system
prompt rules, and the second-pass AI instruction.
- Chat footer: "Powered by ContextVM & Nostr Relays Data" → "Live data from
Nostr relays" (links the primary community relay).
- About page credit: "AI powered by ContextVM" → "Trust & crew chat via
ContextVM" (its real role: Relatr trust scores + encrypted crew chat).
- Owner CVM health panel: clarified it probes the standalone MCP server and
that the in-app chat does not depend on it.
- Updated stale internal comments in SasquatchGuide + CreateSpotPage.
ContextVM references that ARE accurate (Relatr trust scores, crew-chat Cordn
coordinator) were left intact. Build clean, no console errors.
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>
Root cause of the ~35s "spots in bangkok" response: every live-data question
first waited on an AI round-trip just to produce a tool call, and the keyless
backup model (Pollinations) can take tens of seconds. The relay query and local
formatting were already fast — the AI was the bottleneck.
Fix: before touching the AI, try parseLocalIntent(). For clear finder questions
(spots/events/coaches/crews/shops/tours/gear + community stats + weather with a
parseable location) we now go STRAIGHT to the relays (capped at 4.5s) and render
locally — zero AI calls. Expected ~35s → ~2–4s on the most common queries.
Deliberately still routed through the AI:
- trip planning (needs the rich itinerary card)
- personal "my …" Pro queries (get_my_* tools)
- anything without a confidently-parseable intent/location
If the fast-path relay query errors, it falls through to the existing AI loop as
a backup, so nothing regresses.
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>
The shared EventsCalendar already renders tours alongside events with live
countdowns on cards/detail pages. This refines the agenda time display: local
tours now carry a specific session time, so the calendar shows that clock time
instead of a blanket "All day". Multi-day national/international/destruction
tours remain date-only ("All day" / date range), and events are unaffected.
- Add allDay flag to CalendarItem (set from tour category)
- Local sessions show their start time; other tours stay all-day
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Countdown badges (upcoming tours):
- New getCountdown() in TourCard: "In 3 days" / "Tomorrow" / "In 2 weeks" /
"Happening now" (pulsing) based on start/end dates, category-tinted
- Shown on both compact and full TourCard, and in the TourDetailPage schedule card
NIP-05 provider fix (removes obsolete nip05.social):
- New single source of truth src/lib/nip05Providers.ts with working, curated
providers: nostrcheck.me + nostr.directory (free), Nostr Plebs + nostr.band (paid)
- New reusable Nip05Required component (full-page "gate" + inline "banner"
variants) listing those providers and deep-linking to /settings?section=nip05
- Wired into CreateTourPage (gate), CreateListingPage (banner) and all four
Become* pages (Coach/Filmer/Photographer/Builder) for a consistent prompt
- SettingsPage NIP-05 screen now reads from the shared provider list
- Updated internal planning docs to match
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Add a unified Schedule section across all four tour categories:
- Local: choose a fixed session (date+time) or flexible/by-appointment
- National/International: required departure + optional return trip dates
- Destruction: existing competition window, now with end>=start validation
- Reuse start-date/end-date tags for every category (parser already supported them)
- Fix datetime handling: dedicated toDateInput/inputToSecs helpers so date and
datetime-local inputs round-trip correctly (previous mix of toLocalInput +
type="date" was inconsistent)
- Consistent, logical mandatory-field validation with clear required markers
- Redesign the header into an immersive, category-tinted, grunge-striped banner
and rebuild the category selector as elegant glowing cards
- New calendar utilities (.ics + Google Calendar) and AddToCalendar component
- TourDetailPage is now fully category-aware (badge, guide label, duration,
SEO title) with a Schedule card + Add to Calendar for attendees
- TourCard shows the schedule for all categories (time for local sessions)
- Update NIP.md to document start-date/end-date across categories
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Three UX wins so the app feels "already loaded" the moment it opens:
1. IndexedDB React Query persistence (lib/queryPersister.ts)
- PersistQueryClientProvider rehydrates the last-seen spots, shops,
events, tours, crews, coaches, listings and author profiles from
IndexedDB on cold start, so returning users land on a populated map
instead of a blank one; fresh data refetches quietly in the
background.
- Strict allow-list / deny-list: only public, JSON-serializable,
cold-start-relevant queries are persisted. Private/encrypted data
(stash, DMs, contacts, wallet/nwc/cashu/bitcoin, signer) is NEVER
written to disk. 24h maxAge + version buster keep it fresh & safe.
- Pinned @tanstack/react-query + query-core + persist-client to a
single 5.102.8 to avoid a query-core mismatch (partialMatchKey).
2. Optimistic geolocation (lib/lastLocation.ts)
- getCurrentPosition now caches every successful fix.
- The map opens ALREADY centered on the user's last-known area
(city zoom) instead of the world view; "you are here" + proximity
sort seed from the cache instantly.
- "Near Me" flies to the cached location immediately on tap, then
refines to the fresh GPS fix. Silent background warm-up on web map
mount so a later tap resolves instantly.
3. Cold-start skeleton (components/MapSkeletonPins.tsx)
- When the map is genuinely empty + loading, show elegant pulsing
ghost pins + a "Finding spots" pill instead of a blank map; they
vanish the instant real (or cached) spots render. Splash now
signals content-ready when spots actually resolve.
Respects prefers-reduced-motion throughout. Build + runtime verified
clean (no console errors).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Security & reliability audit follow-up. The app was already well-hardened
(strict CSP synced across index.html/_headers/netlify.toml, careful
textContent/escapeHtml in the Leaflet DOM, broad sanitizeUrl usage,
consistent author-filtering on trust-sensitive queries). This closes the
remaining gaps:
- sanitizeUrl is now HTTPS-only: bare http:// links are upgraded to
https:// (not silently dropped); every other scheme (javascript:,
data:, file:, relative) is rejected. Removes a downgrade/mixed-content/
IP-leak vector and matches the native HTTPS-only WebView. Added a
sanitizeCssString() allowlist helper for future CSS interpolation.
- Route-level ErrorBoundary (RouteErrorBoundary) wraps the routed pages,
keyed by pathname: a crash in one page now shows a recoverable
"this page slammed → go back" message while the header/nav/rest of the
app keep working, instead of the single top-level boundary blanking the
ENTIRE app. Resets automatically on navigation.
- ErrorBoundary fallback no longer leaks stack traces to users (full
detail still logged to console for us); redesigned on-brand ("Slammed",
neon, reassures that account + data are safe). Updated its test.
Author-filtering spot-check across admin/newsletter/featured/crew/stash
paths found no gaps — all trust-sensitive queries correctly constrain
authors.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Rebuilt the cold-start splash to a best-in-class standard:
- Determinate progress bar driven by real milestones (mount creep →
relays-warm nudge → 100% on ready) with a sliding shine highlight —
reads as genuine loading, not an infinite spinner.
- Choreographed staggered entrance (logo → wordmark → bar → status rise
in sequence) on an expressive ease curve.
- Signature logo treatment: rotating red→lime conic halo ring + a gentle
breathing pulse, over a dual breathing glow and a masked skate-map grid
texture for depth.
- Satisfying "ready" beat: bar fills to 100%, status swaps to a lime
check "Ready", then a soft scale-up crossfade reveals the app.
- 8px-grid spacing, refined tracking/typography.
- Fully respects prefers-reduced-motion (all motion gated) and safe-area
insets on all four edges; identical on phone and desktop; still capped
hard at 2.6s so it never traps the user.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Make the landing feel "already loaded" instead of dismissing on bare
connection:
- MapPage fires `skatespots:content-ready` once the first spots query
settles. The splash now dismisses on that signal (populated reveal),
falling back to a short grace window after `relays-warm` for non-map
cold-start routes, and still capped hard at 2.2s so it never traps.
- App shell eases in under the departing splash via a pure-opacity
`appReveal` animation (no translate, so a full-bleed map never shifts),
respecting prefers-reduced-motion. Result is a smooth crossfade onto
content that's already there rather than a pop-in.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
A. Relay pre-warming — NostrProvider now opens the read-relay WebSockets
the moment it mounts (fire-and-forget limit:0 probes with a 4s abort),
instead of NPool's lazy open on first query. Sockets are already
connected by the time the map queries run, so first events land sooner.
Failures are swallowed so a dead relay never delays or breaks boot.
B. Branded SplashScreen — an intentional cold-start moment (logo with a
neon glow pulse, a red→lime progress sweep, and rotating skater-voiced
status lines) that replaces the "is it broken?" empty-map flash. It
dismisses the instant relays report warm (`skatespots:relays-warm`
event fired on the first relay response) OR at a hard 1.6s cap —
whichever comes first — with a 650ms minimum to avoid flicker. Shown
once per session. Fully respects prefers-reduced-motion and safe-area
insets; works identically on phone and desktop, never blocks the user.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Root cause: the multi-step SignupDialog is hosted inside LoginArea, but
both the mobile header and the onboarding hero only rendered LoginArea
while logged OUT ({user ? menu : <LoginArea/>}). The signup flow logs the
user in at its FIRST step (to publish the profile), which flipped `user`
truthy and instantly UNMOUNTED LoginArea — destroying the dialog before
the profile and recovery steps could show. The skater was dumped on the
map with no recovery prompt (exactly the reported bug).
Fix: keep LoginArea mounted after login and merely hide its trigger
(dialog renders in a portal, so hiding the trigger is purely cosmetic).
MobileHeaderUserMenu / logged-in CTAs render alongside. The in-progress
signup now survives login and reaches the redesigned recovery step.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Reworked the final signup step so saving your login key is the hero,
not an afterthought:
- Celebration shrunk to a compact bar ("You're in! One quick thing…")
so it no longer steals focus from the safety step.
- "Save your login key" card is now the centerpiece with a loud amber
primary CTA (Reveal & save), a skater-native framing ("keys to your
board", "only way back in — no email, no password reset").
- Download button flips to a green "Saved ✓" state for clear feedback.
- "Skate now" is intentionally quieter and honest: reads "Skip for now,
skate" with a reassuring note until the key is saved, then becomes a
confident lime "Let's skate" once done. Never blocks the user.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Closes the real safety hole: "Remind me later" used to set a PERMANENT
localStorage flag, silently becoming "never remind again" — so users who
never backed up were never reminded again.
- BackupReminder: "Later" now snoozes for the current session only
(sessionStorage), so it returns each visit until backup is actually
done. Only `backup-confirmed` stops it for good. Never blocks the user.
- Downloading OR copying the recovery code (in SignupDialog's done step
and in SecuritySettings) now marks backup-confirmed — genuinely safe
users stop being nagged automatically.
- Plain-language copy pass (EN + ES): "Save your login" / "recovery code"
instead of "nsec / recovery phrase" jargon.
- Non-blocking and identical UX on both phone and desktop (bottom sheet).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Regenerated the Lab skater icon (previous file was corrupted, showing a
broken-image placeholder). Now a valid monochrome neon-red PNG.
- Signup welcome step: added a subtle, jargon-free reassurance line under
"Create quick account" — "Takes 5 seconds · stays on this device ·
nothing to remember" — to make quick signup feel effortless and safe.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Lab "Just a skater?" card now features the uploaded photo, framed and
faded into the card body with a neon-red edge consistent with the app's
look; adds a "The Crew" eyebrow label and a circular arrow affordance.
- Mobile bottom nav redesigned: glowing rounded pill behind the active
tab (replaces the tiny dot), gradient glass bar with a top hairline
glow, ring-2 wallet badge, and refined active/press micro-animations.
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>
The composer derived the kind 30023 addressable `d` identifier straight from
slugify(title). Because 30023 is addressable, two issues sharing a slug —
a recurring "Weekly Roundup", or "Update!" vs "Update?" — would share a
coordinate and the newer publish would silently overwrite the older issue.
Blank slug now auto-generates a unique coordinate (title-slug + UTC date +
short base36 time suffix). An explicit manual slug is still honored verbatim,
so intentionally editing/replacing a specific issue keeps working.
Verified clean (no changes): backfill panel is idempotent (skips slugs already
on-relay, deterministic issue-{n} identifiers) and the subscriber-count hook
uses the standard newest-marker-per-author tally with no double-counting.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audited the spot detail surface — status reports, ownership, and
community contributions. It's well-built:
STATUS (useSpotStatus) — a proper trust×recency consensus: each report
weighted by recencyWeight (30-day half-life) × trustWeight (0.2 floor so
everyone counts, trusted users dominate), only the newest report per
reporter counts (no ballot stuffing), reports past 180 days are dropped,
and the parser rejects any unrecognised status value so garbage can't
poison the tally. Single + batch paths both invalidate on publish.
CONTRIBUTIONS — photos (kind 1063) and tag suggestions (kind 1111) are
SEPARATE events referencing the spot via #a, so contributors never touch
the owner's addressable spot event (no clobber). Tag votes dedupe per
contributor. The detail gallery spreads owner images first so the hero
is always the owner's photo when present; community shots are badged.
OWNERSHIP — transfer/claim model is intentional (claiming republishes
under the claimer's own key = their own coordinate; the original
survives, as the UI states). One real rough edge: handleClaim copied ALL
original tags then appended claim-from/claim-reason, so claiming a spot
that was itself a prior claim would STACK multiple provenance pairs on
the event. Fixed by filtering out any existing claim-from/claim-reason
before appending a single fresh pair pointing at the immediate prior
owner. No functional/authorization change.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audited the wallet surface (real money: Cashu proofs, Lightning, NWC).
Most of it is solid — the Cashu singleton init with browser-CORS mint
probing/auto-heal, the send/receive token flows, the Lightning receive
path (double-tap guard + redeemed-quote listener cleanup + actionable
CORS errors), and the melt-based send all check out and match the zap
path.
One real money-loss gap: removing a mint calls
mintRepository.deleteMint(), which drops that mint's proofs from the
device — so removing a mint you still hold sats in strands those funds.
There are two remove-mint dialogs and they disagreed:
• MintPrivacyManager already warned with the concrete at-risk balance.
• CashuWallet's dialog showed only a generic static "make sure you've
withdrawn funds" line — no balance, no escalation — so a user could
delete a mint holding sats with effectively no warning.
Brought CashuWallet up to (slightly past) parity: when the mint being
removed has a non-zero balance it now shows a loud red warning with the
exact sats at risk and relabels the button "Remove anyway"; empty mints
keep the mild yellow note. Deletion is still allowed (a permanently
offline mint must be removable even with a stuck balance) — it's now an
informed choice, not a silent one.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audited the RSVP / event-update notification flow end to end. The data
layer was already solid and secure — RSVPs p-tag the host, updates
p-tag every RSVPer, and useActivityNotifications guards event-update
spoofing by requiring the author to be the event's host (from the `a`
coordinate) or an admin. The gap was on the rendering side.
Fixes:
- ActivityNotificationCard hardwired every notification to link to the
ACTOR's profile. For an RSVP ("Alice is going to your event") the host
wants the event + attendee list, and for an event update ("moved to
Sunday") the attendee wants to READ the update — not visit a profile.
Both events carry the event's `a` coordinate (31923:pubkey:identifier),
so these two types now route to /event/:pubkey/:identifier. Everything
else still lands on the actor's profile. d-tags containing ':' are
reconstructed correctly.
- useRsvp only invalidated the single-event RSVP query, so the grid /
calendar "going" counts lagged behind by up to the 30s staleTime after
RSVPing. Now also invalidates the batch-event-rsvps query so counts
update promptly.
- Social-tab empty-state copy omitted RSVPs and host updates even though
they land there; made it honest.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Events and tours could only be browsed as a card list — there was no
way to see what's happening on a given date, which is exactly the
clarity the calendar ask called for.
New EventsCalendar component: a purpose-built month grid (not the
date-picker) with:
- Prev/next/today month navigation
- Per-day item dots (max 3 + overflow count) with correct multi-day
overlap handling for ranged events and competition tours
- A selected-day agenda panel showing title, time (or date range /
"All day" for tours), location, and RSVP going counts, each linking
straight to the detail page
- Month total footer, empty-day state, full responsiveness, and
aria-labels / aria-pressed for accessibility
Wired a List⇄Calendar layout toggle into EventsPage for both the
events and tours tabs. Calendar mode reuses the already-filtered
event/tour sets (search, trust, type filters all still apply), hides
the list-only date-window chips, and shows a matching skeleton while
loading. Tours without a concrete date range are omitted from the
calendar (they have no fixed date) and remain browsable in list view.
Going counts are threaded through from the existing batch RSVP query,
so the calendar adds no extra relay load.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Gap found: the app's data model, sales tracking (useSellerSales), and Stash
showcase all key off a listing's status='sold', and both listing cards render a
"sold" state — but there was NO way for a seller to ever set it. Owners had zero
management actions: the MarketplaceItemCard dropdown only rendered for
non-owners (Report), and GearSwapItemCard's owner branch literally said "owners
manage their listing elsewhere" — a place that didn't exist. So sold gear stayed
"active" forever (until 30-day expiry) and the whole sold/sales-count feature was
unreachable.
Added owner-facing management on BOTH listing cards (GearSwapItemCard drives the
main GearSwap grid; MarketplaceItemCard is used on spot pages):
- Mark as sold / Relist as active
- Delete listing (NIP-09, via the existing useDeleteEntity)
New useMarkListingSold hook flips status in place, PRESERVING every other tag
(title, price, images, size, spot ref, geohash, expiration, category t-tags) —
kind 30402 is addressable, so rebuilding from scratch would wipe the photos and
price. Same "preserve unmanaged tags" discipline as the recent event-edit fix.
Also:
- GearSwapItemCard now shows a clear diagonal "SOLD" overlay when sold, so the
status is actually visible to buyers and the owner (previously invisible).
- Generalized its admin-remove flow to handle owner deletes too, with wording
that adapts (owner "Delete" vs admin "Remove").
- DeleteEntityDialog gained an optional controlled open/onOpenChange so it can
be driven from inside a dropdown menu without unmounting on menu close.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Lab provider profiles (coach, filmer, editor, photographer, builder) are
addressable events with a fixed per-type `d` identifier, so re-publishing
REPLACES the existing profile. Unlike BecomeSkaterPage — which pre-fills from
the existing profile so re-submitting edits rather than clobbers — these five
forms start blank and reconstruct nothing. A returning provider who opened the
form again and submitted would silently wipe every field they'd previously set
(bio, rate, spots, contact, links) down to whatever they happened to retype.
Rather than fragile per-page reconstruction of the mixed services/skills tag
sets (high regression risk), added a small honest guardrail:
- New ExistingLabProfileNotice component: appears only when the current user
already has a profile of that type (looked up via useLabProfile by their
pubkey + the known identifier). It states plainly that publishing will
REPLACE the current profile and links to view the existing one first.
- Wired into all five Become* pages with the correct identifier/label.
Also confirmed (not a bug): all five navigate to /coach/:pubkey/:identifier
after publish — CoachDetailPage is a generic Lab-profile renderer that resolves
any type by the distinct `d` identifier, so this is correct despite the name.
Investigation of the shared submit handlers found them otherwise consistent
and well-formed; left untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
QR scanner (CashuWalletCard.handleQRScanResult):
- The classifier recognizes bitcoin_address, npub, and CLINK offer/debit codes,
but all of them fell through to a misleading "Could not recognize the scanned
content" toast. Now each gets an honest, specific message:
• Bitcoin address → "On-chain not supported here" (this is an ecash/Lightning
wallet with no on-chain send)
• npub → "That's a Nostr profile"
• noffer/ndebit → "Payment code not supported yet"
Only genuinely unclassified input shows the generic "unrecognized" message.
MintPrivacyManager:
- Auto-distribute toggle was a live Switch that flipped on and then admitted
"Coming soon — does nothing." Replaced with a disabled switch + "Soon" badge
so users can't think they enabled a feature that isn't real. Removed the now
dead autoDistribute state.
- Dropped unused imports (Progress, Lock, Eye, EyeOff).
The mint manager itself (distribution viz, privacy score, trust/balance
removal warnings) was already excellent and left untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Continued the wallet audit with a focus on intuitive, minimal, elegant UX.
• Recovery phrase — replaced the raw browser alert() (which dumped the seed
as unstyled, uncopyable plaintext) with a proper RecoveryPhraseDialog:
hidden/blurred by default with a deliberate "tap to reveal" gate (no
shoulder-surfing on open), a numbered 12-word chip grid for accurate
transcription, a copy button (enabled only after reveal), and an honest
"paper is safest, clipboards leak" note. Re-hides on every close. This was
the wallet's most sensitive surface and its worst blemish.
• Fixed shared-state bug: "Create Cashu Token" and "Pay Lightning Address"
both wrote to the same `sendAmount`, so switching between them carried a
stale amount across two unrelated intents. The address form now owns its
own amount state.
• Added loading feedback + double-tap protection to every money action that
lacked it — Generate Invoice ("Generating…"), Create Token ("Creating…"),
Receive Token ("Receiving…"), Pay Lightning Address ("Sending…"). Each hook
now exposes an in-flight flag and guards re-entry.
WalletPage and the browser "app-only" notice were already clean/elegant —
left as-is.
Bumps to 1.2.22 (Android versionCode 10222 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audit of the wallet/zap flow surfaced a real money-path bug in ZapDialog's
Cashu branch:
• One-click Cashu zap never worked. The Cashu payment branch only ran
`if (... && invoice)`, but no invoice exists on the first click — so
selecting "Cashu" and tapping Zap silently fell through to the Lightning
flow (generating an invoice / paying via NWC-WebLN) and the user's Cashu
balance was never used. Now the Cashu path generates the recipient's
amount-verified zap invoice and melts it from the Cashu wallet in a single
click.
To support this cleanly, useZaps now exposes `generateZapInvoice()` —
builds + signs the NIP-57 request, fetches the LNURL invoice, and runs the
same `assertInvoiceAmount` security check the Lightning rail uses — WITHOUT
auto-paying. The Lightning `zap()` path was refactored to use it too, so
both rails share one verified-invoice codepath.
• Custom-amount balance guard bypassed. The insufficient-balance warning and
the disabled Send state only checked `typeof amount === 'number'`, but the
custom-amount input stores a string — so a typed amount over the Cashu
balance slipped past both. Both now coerce to a number first.
• Smarter defaults: when the dialog opens and the user has Cashu funds but no
connected Lightning wallet, Cashu is pre-selected and the "connect a wallet"
setup nag is skipped (they can already pay).
• Removed a dead `useLightningOperations` import.
Also audited mobile layout (Layout.tsx — mature, safe-area-aware, no changes
needed) and ZapCrewDialog (uses the separate useCrewZap splitter, no Cashu
option, no bug).
Bumps to 1.2.21 (Android versionCode 10221 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audit outcome for the two suspected "double-prompt" Amber flows:
• Image upload — CONFIRMED already a single signature. BlossomUploader
(even at the pinned 0.47.1) signs one kind-24242 auth event and reuses
it across all servers via Promise.any, so uploads = 1 Amber prompt. No
change needed.
• DM / crew-chat send — the multiple prompts are protocol-inherent and
MUST NOT be optimized away: NIP-17 seals+signs once per recipient (and
to self); a Cordn crew message signs an author proof then encrypts once
per roster member. Reducing the count would weaken confidentiality or
authorship. So instead of a code shortcut we add transparency.
New <AmberApprovalHint>: a tiny heads-up shown ONLY to interactive
(Amber) signers before an inherently multi-approval action, telling them
how many Approve screens to expect and that it's normal. Renders nothing
for silent signers (extension/nsec/bunker), where the ops are invisible.
Wired into:
• CrewChat composer — count derived from transport + roster size
(Cordn: roster+1; NIP-17: roster*2).
• ConversationPage (1:1 DM) composer — 4 approvals (encrypt+sign to the
recipient and to yourself).
Dependencies: reviewed the full manifest; deliberately left unchanged.
Every upgrade candidate is either on the critical crypto/Nostr/payments
path (nostrify, nostr-tools, cashu, bitcoinjs, noble, scure — pinned for
safety) or a documented major bump (lucide v1, react 19, react-router 7)
with breaking changes and no user-facing benefit. No security advisories
force a change, and caret ranges already pull safe patch/minor updates.
"It must work" wins over churn.
Bumps to 1.2.20 (Android versionCode 10220 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Added <ImageWithFallback> — a drop-in <img> that shows a subtle placeholder
(skate emoji or a caller-supplied icon) instead of the browser's broken-image
glyph when a user-supplied URL is missing or 404s. Fills the same layout box so
nothing shifts.
Swapped it into the content-image surfaces that display user-uploaded URLs:
- Marketplace item cards (grid + purchase dialog)
- Spot detail (hero + gallery thumbs) and map list/popup cards
- Shop detail (hero + gallery)
- Crew & Lab cross-link cards (icon fallback)
- Crew agent panel, newsletter issue view + notification bell newsletter thumb
- Stash gear cards (list + grid), profile banner (NIP19), newsletter spot cards
Left untouched: avatars (shadcn Avatar already has fallbacks), QR codes and
rider-card/print images (data URLs, never 404), and in-form upload previews
(local object URLs).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Three places used window.location.href to navigate to bare paths (/signers,
/messages/:npub, /settings). Under HashRouter those trigger a full page reload
to a non-hash path the router can't match — the user got a blank/404 flash
instead of the target screen.
- WhosSkating: DM button now uses useNavigate() instead of a reload.
- StashPage: "Learn About Signers" now uses <Link to="/signers"> (asChild).
- BackupReminder: "Back up now" now uses navigate() AND corrects the deep-link
param from ?tab=security to ?section=security (SettingsPage reads ?section),
which previously dropped the user on the settings root instead of Security.
Also closes the reminder sheet on navigate.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Fixes the audit findings:
HIGH-1 (NWC/WebLN auto-pay of unverified invoices): add assertInvoiceAmount and
call it before every programmatic payment in useZaps, useCrewZap, and
SpamFeePayment. A malicious LNURL/Lightning-address server can no longer return
an inflated invoice and drain the wallet — amount is decoded from bolt11 and must
match the intended sats (±1 sat), amountless invoices are rejected.
HIGH-2 (forgeable trust signals):
- useSasquatchPro: Pro is granted ONLY by a zap receipt AUTHORED by the
platform's Lightning provider, embedding a user-signed zap request that tags
the platform, with a real paid amount >= smallest tier. The self-signed
kind:32671 receipt is now advisory only and never grants access (closed the
"self-grant permanent Pro" hole).
- useFeaturedVerification: a listing is 'verified' only when the matching 9735 is
authored by the provider pubkey (not any self-published receipt).
- useCrewGoal: tally only invoice-backed (bolt11) zaps; the forgeable amount tag
is no longer summed, so goal progress can't be inflated with fake receipts.
Bump to 1.2.14.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Verified against the plugin's Java source: signEvent/nip*Encrypt/Decrypt
IMMEDIATELY reject with "Signer package name not set" when no package is
configured — which is exactly what my earlier "omit packageName" change caused,
and that rejection surfaced as the endless spinner on delete/accept/zap.
With the package set, the plugin tries the content resolver, and when that
returns null (permission not remembered) it falls back to
startActivityForResult(nostrsigner:) — the SAME native call login uses that
reliably opens Amber. Restore native plugin signEvent/nip04/nip44 with
setPackageName + AMBER_PACKAGE, reconstruct signature-only responses, and remove
the unused JS-intent bridge. Bump to 1.2.9.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The capacitor plugin's signEvent/encrypt query Amber's ContentProvider first,
which stalls on GrapheneOS and never launches Amber's approval UI — causing
delete, accept-invite, zaps, and every signing action to hang forever.
Add amberIntentBridge.ts which drives Amber through the reliable
intent:...scheme=nostrsigner flow (the same path login uses that actually opens
Amber), returning a promise resolved in-process via the org.skatespots.app://
deep-link callback (DeepLinkHandler → resolveAmberIntent). CapacitorAmberSigner
now routes signEvent/nip04/nip44 through this bridge instead of the stalling
ContentResolver path. Fast 60s fail-safe with a clear message instead of an
indefinite spinner.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The badge counted ALL actionable crew items (invites, join requests,
pending confirmations) regardless of read state, so "Mark read" (and even
opening the bell) never cleared it. Now the badge counts only items newer
than a per-account "seen at" timestamp that "Mark read"/open updates.
Actionable crew cards still render in their tab until acted upon — only
the red badge clears — which is the expected behavior.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
DELETE HANG (real fix):
The admin takedown required TWO sequential Amber approvals (kind:5
deletion + kind:1985 hide label) and awaited both. On mobile the second
Amber prompt often doesn't resurface, so the button sat on "Deleting…"
until the 90s signer timeout. Now:
- Only the kind:5 deletion is awaited (the required action).
- The target is recorded in a local optimistic-hide store immediately, so
the app hides it on this device the instant kind:5 succeeds — no waiting
on a second signature.
- The cross-device kind:1985 label is fired in the BACKGROUND (not
awaited); a second approval, if needed, happens after the dialog closes.
- The whole mutation is wrapped in a 40s hard timeout so the spinner can
never hang indefinitely, whatever the signer/relay does.
- useAdminModeration merges the local optimistic set so hides are instant
and survive reload.
NOTIFICATION BELL UI:
- Brand-lime (#ccff00) glowing badge with ring, replacing the flat teal dot.
- Bell wiggles + soft glow pulse when there are unread items (new `wiggle`
keyframe).
- Gradient header with an icon chip, inline count, and a pill "Mark read"
button / "All clear" state.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Extends the admin-delete hang fix across the whole app. Audited every
signing route:
- Native (CapacitorAmberSigner): already fixed — computes id locally.
- Web/PWA (AmberSigner redirect flow): the callback handlers did a raw
JSON.parse(result), but Amber returns a BARE signature (returnType=
'signature'), so this threw or produced an event with an empty id that
stalls the relay — the same "stuck" defect as native. Added a shared
reconstructSignedEvent() helper that rebuilds the event from the stored
template + signature and computes the id via getEventHash. Applied in
both AmberCallbackPage and AmberCallbackHandler.
- NIP-17 DMs, zap requests, vouches, crew invites, membership, etc. all
sign via user.signer.signEvent and therefore inherit the fix.
- nsec / bunker / extension signers use Nostrify base classes (compute
ids internally) — unaffected.
- Native nip04/nip44 encrypt/decrypt are timeout-protected — no hang.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
#2 (crash): useDirectMessages called getEventHash() without importing it —
`getEventHash is not defined` broke crew chat and any NIP-17 DM send. Added
the missing import from nostr-tools.
#3 (bell not loading on phone): the notifications popover had a fixed-height
scroll area and no collision handling, so on small screens it rendered clipped
against the top edge with the tab content cut off. The popover now constrains
to the Radix available-height, uses a flex column, and the tab list/scroll area
flex correctly — all tabs (incl. Crews/News) are reachable on mobile.
#4 (multi-account on phone): the mobile header user menu had only Set Status +
Log Out. Added an Accounts section that lists other logged-in npubs to switch
between, plus "Add another account" which opens the login dialog. Desktop
already had this via AccountSwitcher; mobile now reaches parity.
Note: #1/#5/#6 (stuck on publish/vouch/delete) were reported against the shipped
1.2.2 APK, which also contained the #2 crash. Rebuild 1.2.3 and re-test — the
sign/publish paths in current source are correct (Amber round-trip has a 90s
timeout + surfaced errors).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Two follow-ups to the Nostr-native newsletter (skipped auto-DM by design —
too spammy/fragile for now; the bell mailbox already covers the inbox feel).
Subscriber count (social proof):
- useNewsletterSubscriberCount tallies public NIP-78 subscription markers
(deduped per author, counting current "true" state).
- Shown under the subscribe CTA as "Join N+ skaters getting the drops",
hidden until it's meaningful (>=3) and rounded once it grows.
Archive backfill (consistency without losing the rich page):
- lib/newsletterArticle.ts converts a static NEWSLETTER_ISSUES entry into a
NIP-23 (kind 30023) article — structured sections rendered to Markdown,
stable slug `issue-N`, published_at from the issue date.
- NewsletterBackfillPanel (admin-only) diffs the static archive against what's
already on-relay and publishes only the missing issues; safe to re-run,
reports partial failures. The rich static page rendering is untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>