- Add a More/Less toggle (remembered in localStorage) that expands the focus
toggle group from short labels to full names, wrapping instead of scrolling.
- Empty states are now context-aware: a search miss offers 'Clear search'; an
empty scene filter reads 'No <Focus> crews yet' with 'Show all crews' rather
than wrongly claiming no crews exist; a genuinely empty board keeps the
'Claim your local scene' recruiting loop and drops the no-op near-me CTA.
- Replace the eight wrapping filter chips with a single-row ToggleGroup
(short labels; full names in tooltip/aria-label). Scrolls horizontally on
mobile instead of wrapping to two rows.
- Near me is now mobile-only on the Crews page — it was clutter on desktop.
- Lab forms: a non-empty email must be valid and website/portfolio must
resolve to an https URL; invalid input shows an inline hint and blocks
publish. Adds isValidEmail() (reused by sanitizeEmail). social/instagram
are exempt since they may be bare @handles.
- New PhotoDropzone component: drag & drop plus multi-select image input,
emitting File[] to the caller. Wired into CreateSpotPage + EditSpotPage
(drop several photos at once) and AddPhotoToSpot, replacing the old
one-file-at-a-time inputs.
- Spot photo uploads run sequentially and dedupe URLs.
- Preserve the geo 'g' tag and paid 'featured' tag when editing any Lab
profile (all 7 craft roles via CraftProfileForm + 5 bespoke pages).
- Render previously collected-but-hidden fields on the Lab profile page:
judge 'events_judged', organizer 'events_hosted', editor hardware/software,
and spot references.
- Include Event Organizer (35013) in Community Stats + counts.
- Cap Title inputs at 100 (was 120) so profiles no longer fail the parser
and silently vanish; MIN_TITLE_LEN 2 -> 1 for 1-char display names.
- Normalize bare-domain URLs (example.com) to https:// before storing instead
of dropping/hiding them (new normalizeUrlInput helper; fixes extra url fields).
- No crew strip for judging/organizing (was wrongly showing Photo & Film);
scrollable Offer Services FAB; live bio counters.
Finishes the Lab audit: the 5 bespoke 'become' pages now prefill from your
existing profile (edit, not blind overwrite) and expose a 'Headline' field used
as the profile title. Also:
- submit button shows Update... when editing; success toast says updated
- removed the dead ExistingLabProfileNotice (the warning it gave is now moot)
Every one of the 12 Lab provider flows (5 bespoke + 7 craft) now edits + titles
consistently. Gate: 101 tests, 0 errors/warnings, boot smoke green.
Audit of the Lab 'offer a service' flows found two cross-cutting gaps:
1. Every Lab card showed the name TWICE (the card renders a 'title' line, but
all forms wrote title = the kind-0 display name). The field is meant to be a
self-described headline.
2. Only the Skater flow could be edited; the 7 craft roles started blank and
silently overwrote the previous profile on submit.
Fixes (shared CraftProfileForm -> 7 roles: artist, graffiti, tattoo, trainer,
musician, judge, organizer):
- New useMyLabProfileEvent(kind, identifier) hook; the form prefills from your
existing profile and becomes an EDIT (button: Update Profile; toast: updated).
- New 'Name / headline' field -> the tag (defaults to your name).
- Lab ProfileCard only shows the title line when it differs from the name.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
Location is a mobile-only affordance now (the desktop Near Me button was removed),
so stop seeding the cached location on desktop and don't pass userLocation to the
map on desktop. Result: no 'You are here' badge and no proximity sort on desktop;
mobile is unchanged.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
Poor fit for a skate app: it only funneled users to install a third-party VPN
app (QR join request + download links + platform detection) with zero in-app
function. Removed the sub-screen, its Settings menu row, the section enum entry,
the render switch, the nostr-vpn constants, and the now-unused imports.
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.
Extends LazyMount beyond the marketplace so these long card grids no longer
mount every card (and its hooks/DOM) up front. Cards keep equal height via
h-full wrappers.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
- LazyMount: mount a grid card only when it scrolls within 600px (skeleton-space
placeholder otherwise), so a long feed no longer mounts every card + its
hooks/DOM up front. Falls back to immediate render without IntersectionObserver.
- GearSwapItemCard: clicking the card now opens /listing/:pubkey/:identifier
(was opening a DM). 'Contact Seller' still opens the DM. Card set to h-full so
the lazy wrapper keeps the equal-height grid.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
On the listing detail page, buyers get 'Make an offer' (opens the DM with the
seller pre-filled with an offer message about the listing) and 'Share listing'
(copies the URL). Replaces the redundant 'Message' button (Contact Seller is
already in the shared trade actions).
Gate: 101 tests, 0 errors/warnings, boot smoke green.
On /trades, each authored trade record gets a 'Revoke' action (with a confirm
dialog) that republishes the 31871 with s=revoked — retracting it from
reputation for a disputed or mistaken deal. The counterparty keeps their own
attestation. Revoked records are already excluded from aggregation.
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.
- Mints page: 'Online only' toggle + result count ('N of M mints')
- Wallet page: 'Browse Cashu mints' link to /mints
- ListingTradeActions now renders the EscrowPanel for a viewer's active escrow,
so escrow status shows on both the grid card and the detail page
- AGENTS.md project map: marketplace detail page + shared actions
Gate: 101 tests, 0 errors/warnings, boot smoke green.
So Local relays are used and Blocked relays are excluded EVERYWHERE, not just in
the NPool routers:
- useListingReports, useSellerInfo, useSkateRelays (read+write), useZaps
(NIP-57 relays tag), usePurchasePro, CollectionDetailPage, CrewForm,
useDirectMessages write fallback
- AppProvider's empty-relays defaults check is the only intentional direct reader
- AGENTS.md updated
Gate: 101 tests, 0 errors/warnings, boot smoke green.
- New '/listing/:pubkey/:identifier' page: large gallery + lightbox, full
description, details, seller trust + trade reputation card, and full actions
(shared) for buyers and owners (mark sold/relist/delete)
- Extract ListingTradeActions (confirm/escrow request/ask-to-confirm/refund/
contact + escrow dialog) and use it in BOTH the grid card and the detail page
so there is one implementation
- useMarketplaceItem(pubkey, identifier) fetch hook
- Cards link their image + title to the detail page
- Smoke test covers /listing/:pubkey/:identifier
Gate: 101 tests, 0 errors/warnings, boot smoke green.
The last 7 react-refresh warnings are dev-only HMR DX from intentional
component+hook/helper colocations (EventCountdown, SortDropdown, TrustFilter,
LanguageContext, NewsletterPage). Splitting them into new files is broad import
churn for no runtime benefit, so each carries a justified file-level disable.
npm test: 0 errors, 0 warnings, 101 tests; boot smoke green.
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.
Data-driven pass (adds rollup-plugin-visualizer behind ANALYZE=1):
- MapPage was statically imported by the router, dragging the map route AND the
IndexedDB offline-cache layer into the entry -> now lazy()
- NewsletterOverlay imported NEWSLETTER_ISSUES from the whole NewsletterPage
module -> types + data moved to lib/newsletterIssues.ts
- SasquatchGuide (assistant overlay) lazy-loaded in Layout
- Cashu stack is now a separate chunk (useCashu), off the critical path
Net: entry down ~61%; first paint no longer parses the map, the newsletter
page, the assistant or the wallet stack.
PageHeader now carries the app's neon glow (accent-tinted text-shadow), so the
unified header keeps the brand look rather than flattening it. Four more plain
headers migrate (title + accent + optional subtitle/actions).
- New shared PageHeader + PageContainer (width tokens: narrow/list/detail/wide)
to end the max-w drift between sections; piloted on ContactsPage
- Profile: the identity/contact sidebar now comes first on mobile (order-first /
lg:order-none) instead of being buried under every content section
showAwardDialog was never set true, so the dialog could never open; the inline
AwardBadgeInline form is the live flow. Removes ~142 lines plus its now-unused
Dialog imports, state and helper.
The calendar always initialised to the current month, so the Past tab (all
events in the past) opened on an empty month. It now anchors to the newest
past event, and re-anchors when the tab changes.
- Profile showed the live status indicator twice (avatar + name row) -> once
- Stash/Wallet informational text bumped from gray-600/700 to gray-400/500 for
readable contrast on the near-black background
- Map view now shows a 'No spots match / Clear filters' overlay when the active
filters hide everything (it previously looked blank/unloaded)
- 'List Gear' passes returnTo=/marketplace, so the back link and the post-publish
redirect return to Marketplace instead of dumping users on the map
- Bounty leaderboards no longer slice to top 50 before computing the current
user's rank (anyone outside the top 50 never saw their position), and each row
no longer prints the count twice
- Lab craft tabs: 4-up grid (11 tabs were laid out 5-across, leaving a lone
'Judges' orphan row); stale comment corrected
- Contacts search placeholder now says it filters by npub (it never matched the
displayed names)
- ShopDetailPage gallery reuses ImageLightbox: photos are now zoomable and the
whole set is reachable (it showed at most 5 and clicks did nothing)
- FAQPage gains static quick answers so the page is useful even if The
Sasquatch guide fails to open (it previously had zero intrinsic content)
- SpotPicker showed 'No spots found' during the initial load (also affected
CreateListing); now shows a loading state first
- Events: switching to the calendar view clears the date-window filter, which
only has chips in list view — it was silently hiding calendar events
- EmptyState: render <Link> as the button (a <button> inside <Link> was invalid
and double-focusable); AboutPage <a href> -> <Link> (no full reload)
- aria-label on icon-only controls: settings Back, conversation Refresh/Send,
crew chat Send
- Crew invite Accept/Decline raised to 44px touch targets
- geolocation: cap the overall deadline at 15s (was 25s) and shorten the native
plugin attempt to 8s, so a failed fix no longer hangs ~25-40s
- MapPage: only warm the location cache when permission is ALREADY granted, so
opening /map no longer fires the browser's 'Allow location?' prompt
- MapPage: add a desktop Near Me control (it was mobile-only)
- MapPage: on native, a denial is now retryable instead of permanently opening
the browser-only help sheet
Adds a shared ErrorState/PageError with a retry action. Detail pages (spot,
shop, collection, coach, crew, event) now show an honest failure + Try again
instead of '404 Not Found' — a relay hiccup previously implied the content was
deleted. The zap/bounty leaderboards surface errors instead of 'no content yet',
and the DM inbox gains a retry button.
- Stash: remove the handler-less 'Share via DM' button; action overlay stays
visible on touch (was hover-only, so edit/delete were unreachable on mobile)
- Crew invite accept no longer claims membership is confirmed (only the founder
ratifies the roster) — both the toast and the accepted state now say so
- Crew invite share message no longer promises a one-click join
- Map marker: don't bind the Leaflet popup when a host supplies onSpotClick
(MapPage's bottom sheet) — both used to appear at once
- SpotDetailPage: 'Claim This Shop' was a no-op button; wire to NIP-05 settings
(and stop the full-page <a href> reload)
- TourDetailPage: 'Contact Guide to Book' used /messages?to= which nothing read,
so booking silently opened an empty inbox -> /messages/:npub
- ContactsPage: row 'Message' went to the generic inbox -> the contact's thread
- SettingsPage: ?section=follows|language|stash rendered a blank dead end;
unknown sections now fall back to the menu
- DirectMessages/Conversation: logged-out states were dead ends -> add LoginArea
- Conversation: failed sends were silent -> error toast
CreateEventPage gains an optional 'Funding Goal (sats)' field that publishes a
'goal' tag. EventDetailPage shows a FundingGoalCard with live raised/target
progress, backer count and a Fund button — funded peer-to-peer via NIP-57 zaps
(no escrow).
Derives First Zap / Regular / Generous / Patron / Top 10 from the batched
30-day supporter leaderboard and shows earned vs locked badges in the profile
sidebar. Client-side recognition only — publishes no events.
Shows the four most-zapped spots (last 30 days) on the home page, with their
sats totals, linking through to /leaderboards. Renders nothing when there is
no zap activity or no linkable spot, so the section never appears empty.
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.
Gallery images were inert (click did nothing) and only the first five were
reachable. Adds ImageLightbox: click any photo to view full size, arrow-key /
button navigation across the whole gallery, position counter, caption and
community-photo attribution. Extra images show a +N overlay.
The spot page (and the other detail pages) used `grid lg:grid-cols-3` with
no explicit mobile column. Below the `lg` breakpoint the grid falls back to
a single implicit `auto` track, whose minimum is the min-content of its
items. A long mapper name ("Tip the Mapper" card) gave the sidebar a
~559px min-content, so the whole grid widened past the viewport and was
clipped by the global `html, body { overflow-x: hidden }` — content cut off
on the right with no way to scroll to it.
Fixes:
- Add `grid-cols-1` to detail-page grids so the mobile track is
`minmax(0, 1fr)` and can never exceed the container. Applied to Spot,
Shop, Event, NIP-19, Crew and Coach detail pages.
- Add `min-w-0` to their sidebar/main columns so long content can shrink
and truncate instead of forcing the track wider.
- Make the Spot Chat header wrap (`flex-wrap` + `gap-y`) so the trust
filter and channel badge no longer get clipped on narrow phones.
- Replace single-assignment `let timer` with a `timers[]` array in
citrine.ts and relayProbe.ts so stricter prefer-const builds pass
(the newer ESLint pulled in by `npm audit fix` flagged the let as
never-reassigned).
- Drop two now-redundant `eslint-disable react-hooks/exhaustive-deps`
directives in NostrProvider and MapPage. Newer react-hooks no longer
reports those lines, so the directives were flagged as unused errors
under reportUnusedDisableDirectives; removing them is safe in both
old (warning only) and new (silent) ESLint.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Add aria-live="polite" to the three dynamic surfaces screen-reader
users otherwise miss:
- Notification bell unread-count badge (with an "N unread notifications"
label so the number is announced meaningfully, not read as bare digits)
- Cashu wallet balance value (announces the new balance after a
send/receive completes)
- Direct-message conversation thread: role="log" + aria-live so
incoming messages are announced as they arrive
ToastViewport already announces via the Radix default and was left
untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The empty-state migration removed the `cn` import from both files on the
mistaken belief it was unused. In fact each file still uses cn() ~22 times
(ProfileCard, FilterDrawer, event cards, etc.), so /lab and /events crashed
at runtime with "cn is not defined" and hit the route error boundary.
Re-added `import { cn } from '@/lib/utils'` to both.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Empty states:
- LabPage and NotificationBell now use the shared <EmptyState> component.
- LabPage's logged-out "Log in to create a profile" prompt preserved via
the hint slot; removed now-unused cn import.
Page titles (U2):
- Audited all page routes for document titles. Only 3 were missing:
- EditTourPage: now sets "Edit <tour> - SkateSpots".
- AmberCallbackPage: now sets "Signing in with Amber - SkateSpots".
- NIP19Page ProfilePage: sets "<name> - SkateSpots" with about as
description; other NIP-19 branches already delegate to NotFound /
NewsletterIssueView which set their own titles.
ScrollToTop (U1): verified already correct — resets window scroll on every
pathname change, wired in AppRouter. No change needed.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Empty states (P4 follow-up):
- CrewsPage and EventsPage now render the shared <EmptyState> instead of
their own local copies, keeping spacing/actions/footer consistent.
Become-role routes (P1):
- Add src/lib/becomeRoles.tsx: single registry mapping each role slug to
either a CraftProfileForm config (artist/graffiti/musician/judge/tattoo/
trainer) or a lazy bespoke page (coach/filmer/editor/photographer/
builder/skater). The 6 craft roles were already thin config wrappers, so
their configs move into the registry and the wrapper files are deleted.
- Replace 12 hardcoded /become-* routes + 12 imports with one
/become/:role route (BecomeRolePage) that renders from the registry.
- Legacy /become-<slug> URLs now redirect to /become/<slug> so shared/
bookmarked links keep working.
- Update all internal links (LabPage, Index, SkatersPage, SpotDetailPage,
NIP19Page, SkaterDirectoryCard) to the canonical /become/<slug> paths.
Note: the 5 large bespoke forms were intentionally NOT merged into one
config — they carry distinct machinery (SpotPicker, wallet/Pro, geohashing)
and forcing them together would increase complexity, not reduce it.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The cold-start loading state scattered fuzzy pink "ghost pins" across the map
(the same blob look that was disliked on real markers) which read as broken.
Removed MapSkeletonPins entirely.
New loading state, per the agreed plan (keep the map instant, never cover it):
- A slim animated top progress bar ("ss-loadbar" sweep on a faint track) that
sits above the map while spots stream in.
- During a genuine cold start (no markers yet), a small labeled "Loading spots"
pill with a spinner near the top — honest about what's happening (querying
relays), not fake pins on the ocean.
- Reduced-motion respected (bar becomes static).
The map itself is untouched and still renders immediately; only the placeholder
changed. Dropped the now-unused MapSkeletonPins import. Build clean, no console
errors.
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>
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>
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>
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>
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>
New skaters landed on the onboarding page with the recovery/backup
prompt below the fold, so it read as "nothing about recovery". Moved the
critical "Save your login" banner ABOVE the hero (stronger border, glow,
uppercase headline) so it's the first thing a newly-signed-up user sees.
Removed the duplicate lower banner. Still non-blocking and stays until
backup-confirmed; the post-login bottom sheet remains as a second net.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The PNG icon kept failing to render (corruption + browser caching of the
broken response) on both phone and desktop. Replaced it with the
PersonStanding Lucide vector icon in neon red with a red glow — it always
renders, never caches broken, is truly single-color and on-brand, and
works identically across phone and desktop. Removed dead image assets.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Swapped the busy multi-color icon for a clean, monochrome neon-red
skater glyph that reads clearly at small sizes. Refined the tile with a
subtle red border and clearer hover state for better visibility.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Replaced the large black-and-white photo panel with a compact,
app-styled skater icon (neon red #ff0040 + lime #ccff00) sitting in the
rounded icon tile used elsewhere in the app. Added a red drop-shadow
glow to match the brand aesthetic. Removed the unused photo asset.
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>
Two greenlit onboarding improvements, keeping the existing layout intact.
#5 Save-your-login banner (the safety one):
- Right under the green "You're in!" bar, logged-in users who haven't backed up
see a prominent amber banner — "Save your login — do this now" — that taps
straight to Security settings. It reads the shared `backup-confirmed` flag
(same source of truth as BackupReminder), so it PERSISTS until they actually
back up; unlike the dismissible reminder sheet, it can't silently turn into
"never". Once confirmed it flips to a calm green "Login saved" note. Plain
language ("save your login", not "back up your nsec"). Pulse dot respects
reduced-motion.
#1 Lighten first-glance load:
- The 9-item FAQ ("Common questions") is now a collapse-by-default section — one
tidy row until tapped — via a small reusable CollapsibleSection (aria-expanded,
reduced-motion aware). Nothing removed; the page just feels lighter on arrival.
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>
The real fix for "Citrine running but 0 connections": the app is served over
https in the WebView and Citrine listens on plaintext ws://127.0.0.1, which
modern Android WebViews block as mixed content BEFORE it reaches the network
layer — allowMixedContent + the loopback network-security-config don't fully
cover WebSocket mixed content.
MainActivity now sets the bridge WebView's mixed-content mode to
MIXED_CONTENT_ALWAYS_ALLOW after super.onCreate(), which lets the localhost
ws:// connection through for every user. All real traffic stays wss:///https://;
this only unblocks the loopback relay. The WebViewClient is left untouched
(overriding it previously caused a black screen). Wrapped in try/catch so it can
never crash the launcher.
Also removed the temporary on-screen probe debug readout (kept the diagnostic
fields in useCitrine, unused by UI, for future use).
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>
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>
Two real bugs surfaced auditing the Map page.
1. The "trusted-first" soft sort was silently dead for the default
config. filteredSpots ran a standalone .sort() to float trusted
content up, then IMMEDIATELY ran a second unconditional .sort() by
sortBy — which re-ordered everything and wiped the trust ordering.
For a logged-in user with ≥5 follows the smart default is
trustFilter='trusted' + sortBy='recent', so the documented
"sort trusted first, badge unverified" behaviour never actually
happened. Fixed by folding the trust tier into the MAIN comparator as
its primary key, with the chosen sortBy as the tiebreaker within each
tier. Strict modes (verified/following) still hard-filter as before.
2. MiniStashCard (gear pins in the map drawer) built its border colour
with an interpolated Tailwind class (`border-${colour}-500/40`) that
the JIT compiler can't see, so it was never generated — dead class
emitting an invalid string into the DOM. The inline
style={{ borderColor }} that actually worked was also applied
unconditionally, overriding the intended cyan border on WISHLIST
items with the rarity colour. Now: wishlist keeps its static cyan
border; gear uses the rarity colour via inline style only.
Geolocation (native vs web, denied/timeout/no-provider handling),
batched trust/status/comment/weather queries, and the layer plumbing
into SpotMap were all audited and are sound.
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>
Tours (kind 30823) were the only major addressable entity with no edit path: an
organizer had to delete and recreate (losing the URL/identifier) just to fix a
typo or price. Rather than duplicate the enormous multi-category tour form (and
risk schema drift / the exact tag-clobber bugs fixed earlier this session), I
made the existing CreateTourPage do double duty:
- CreateTourPage now accepts an optional `existing?: SkateTour`. When present it
prefills every field (all four categories: local/national/international/
destruction — including challenges, prize distribution, film-release, partner
shop, cities/countries, coords, dates), reuses the same `d` identifier, and
adapts copy (title, submit button, toast).
- On save it PRESERVES unmanaged tags (sponsors, tags from other clients) and
keeps any existing paid `featured` window — same "preserve unmanaged tags"
discipline as the event/shop edit fixes, since 30823 is addressable and
re-publishing replaces the whole tag set.
- Guarded the category→duration reset effect so it no longer clobbers the
prefilled duration on the first render in edit mode.
- The paid FeaturedUpgrade upsell is hidden when editing (existing status is
carried through automatically).
New EditTourPage wrapper loads the tour, gates to the owner (login / access-
denied states), and renders <CreateTourPage existing={tour} />. Added the
/edit-tour/:pubkey/:identifier route and an owner-only Edit button on
TourDetailPage beside the existing Delete control.
This closes the one concrete gap the addressable-events audit surfaced: every
entity now has both a working edit path (preserving unmanaged tags) and owner
management.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Removed the leftover console.log of the full tour event payload in
CreateTourPage — the last of the "Publishing … event" debug logs (spot, shop,
tour all now clean).
Audit findings for Tours (no code change beyond the log):
- Tours (kind 30823) are create-only with a timestamped identifier, so there is
NO edit path and therefore no edit-clobber risk to fix. (There's also no
EditTourPage / /edit-tour route at all — a genuine UX gap where an organizer
can't fix a typo/price/meeting-point without deleting and recreating, but
building an editor for the large multi-category tour form is a feature, not an
audit fix, and rushing it would risk the exact tag-clobber bugs just fixed
elsewhere. Leaving it flagged rather than half-doing it.)
- Owner/admin management is already correct: TourDetailPage renders
DeleteEntityDialog, which internally gates to owner-or-admin.
This wraps the addressable-events pass (spots, events, shops, tours, Lab
profiles, crews, marketplace): every EDIT path either preserves unmanaged tags
or was confirmed safe, and every entity has a working owner management path
except tour-editing, which is now explicitly flagged as future work.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
EditShopPage rebuilt a shop's tag set from scratch on save. A shop (NIP-15
stall, kind 30017) is addressable, so re-publishing REPLACES all tags — any tag
the form doesn't manage would be dropped. The app's own SkateShop parser only
reads the managed fields today (so no current in-app data loss), but a stall
created/edited in another Nostr client can legitimately carry currency,
shipping, or other NIP-15 tags that would vanish on the first SkateSpots edit.
Applied the same "preserve unmanaged tags" passthrough used for event edits:
keep every non-managed tag untouched, with `t` handled specially (form owns the
'skateshop' marker, foreign topics preserved).
Also removed a leftover console.log of the full shop event payload in
CreateShopPage (matching the earlier spot-form cleanup).
Audit note (no change needed): ShopDetailPage already provides full owner
management — an Edit link and an owner/admin Delete with confirmation via
useDeleteEntity — so unlike the marketplace listings fixed in 1.2.30, shops
were never missing a management path.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Calendar events (NIP-52 kind 31923) are addressable, so re-publishing replaces
the entire tag set. The edit form only rebuilt the handful of fields it exposes
(title/start/end/location/geohash/types/image) plus sponsor tags. Everything
else a CreateEventPage event can carry was silently dropped on any edit:
- linked spot / crew host / tour / musician `a` refs + their readable
name/category tags (spot-name, crew-name, tour-name, music-act, …)
- entry-fee and the 'paid' topic tag
- the paid 'featured' (Pro) listing the organiser may have bought
So a founder editing their event's start time would lose the crew-host link,
the entry fee, and their Pro featured status — real, paid-for data loss.
Fix: after rebuilding the managed fields, carry through every OTHER tag from
the original event untouched. `t` (topic) tags are handled specially — the
form fully owns 'skateboarding' + selected types, but foreign topics like
'paid' are preserved. Replaces the narrow sponsor-only passthrough.
Audit note: CrewForm (create + edit) and EditSpotPage were reviewed in the
same pass and correctly reuse their identifier / preserve state — no changes
needed there. Crews are intentionally many-per-user (timestamped identifier),
so re-creating is by design, not a clobber.
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>
CreateSpotPage:
- Spot type is marked required (*) and drives the quality meter, but submit
never validated it — a spot could be published with no type. Now validated
with a clear toast, matching the title/coordinate checks.
- Submit was allowed while the Quick Snap photo was still uploading in the
background, which would publish the spot WITHOUT the photo the user just
took. Submit is now blocked (with an "Uploading photo…" button state and a
reassuring toast) until the upload settles.
- Removed a leftover console.log of the full event payload.
EditSpotPage:
- Same missing spot-type validation (also marked * in its UI) — added the
same guard for consistency.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Same encrypted-replaceable-list data-safety pattern as the Stash fix, applied
to usePrivateContacts (kind 30000, NIP-44 encrypted to self).
add/remove built the new list from the React Query cache, which is empty until
the Contacts page has decrypted — and with Amber the read query is gated /
staleTime:Infinity. Saving a contact before that decrypt (fresh device, or if a
"save privately" action is ever wired up elsewhere) could publish a list with
only the new pubkey and silently wipe every other private contact.
Fix: add/remove now call fetchLatest() — a fresh relay fetch + decrypt of the
current list immediately before publishing — and union it with the warm cache
so an optimistic just-added pubkey is never dropped. On fetch/decrypt failure
it falls back to the cache rather than risk a wipe.
UX: clarified the contacts search placeholder ("Paste an npub to filter…") so
it no longer implies name search that the npub-only filter doesn't do. Left the
star toggle un-confirmed on purpose — it's a low-stakes, one-tap-reversible
action and a dialog would only add friction. ContactsPage is otherwise already
clean/elegant (tabbed, skeletons, thoughtful empty states, encryption notice).
Bumps to 1.2.24 (Android versionCode 10224 auto-derived).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audited the Stash — a private, NIP-44-encrypted, Amber-sensitive surface.
DATA-SAFETY FIX (the important one):
The stash is a single replaceable event whose encrypted content is the WHOLE
item list, so every add/update/remove rewrites the entire event. The mutations
built the new list from the `stash` closure value — but that's legitimately
`undefined` before the decrypt completes, and with Amber the read query is
gated / staleTime:Infinity, so it may never have been decrypted at mutation
time. Result: adding one item while `stash` was empty/undefined could publish a
list containing only that item and silently WIPE all existing gear.
Fix: mutations now call `fetchLatestStash()` — a fresh relay fetch + decrypt of
the current stash immediately before saving — and build the new list on top of
that. It falls back to React Query cache (never an empty list) on failure, and
throws rather than risk a clobber if it truly can't load. saveStash also writes
the just-published value straight into the cache to keep reads consistent.
The extra decrypt per mutation is an acceptable Amber tradeoff: a mutation is
already a deliberate write (encrypt + sign prompt), and data integrity beats
prompt count.
UX:
• Delete now asks for confirmation via an AlertDialog (item name + a note that
public items get unlisted). Previously a single tap wiped an item outright —
destructive and, with Amber, a surprise signer prompt.
The rest of StashPage (login gate, encryption-required state, distinct
wishlist/stash/filtered empty states, skeletons, rarity cards, grid/list) is
already excellent — left as-is.
Bumps to 1.2.23 (Android versionCode 10223 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>
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>