packages/skatespots-background: a local Capacitor Android plugin that polls our
own push-service /pending and raises local notifications. Modes: off / standard
(WorkManager ~15 min) / fast (foreground service ~5 min). Kotlin +
AndroidManifest (POST_NOTIFICATIONS, FOREGROUND_SERVICE_DATA_SYNC, WAKE_LOCK,
battery-exemption) + Gradle module. Client: useBackgroundDelivery (registers a
poll token via NIP-98, seeds the plugin) + a Background delivery (Android)
control in NotificationSettingsCard. The app registers by NAME, not by importing
the package, so the web bundle is unaffected. UNVERIFIED - needs cap sync + a
device.
Server: /native/subscribe + /native/unsubscribe (NIP-98 proof), FCM device store,
and FCM HTTP v1 sending (service-account OAuth2, cached ~1h); /health reports
native + device count. 503 without PUSH_FIREBASE_SERVICE_ACCOUNT_JSON.
Client: @capacitor/push-notifications (lazily imported, native-only) in
usePushNotifications - request permission, register, store the FCM token, and
subscribe via /native/subscribe; tap deep-links into the app.
Needs a Firebase project: google-services.json + the Gradle plugin (android/),
and the service-account JSON in the host .env.
Our own keyless Bun service (not Linky's): watches relays for kind-1059 wraps
carrying the plaintext marker ["skatespots","push"] on the recipient copy,
extracts only {recipient, wrapId}, and sends a Web Push. NEVER decrypts - the
client fetches + decrypts the named wrap itself.
Endpoints: GET /health, GET /vapid-public-key, POST /auth/challenge ->
/subscribe|/unsubscribe with a one-time NIP-98 (kind 27235) challenge proof.
State is a JSON file; VAPID keys from env. Wired like the other standalone
servers (typecheck:push + check:push in npm test, deploy:push, Caddyfile +
systemd unit). Verified locally end-to-end: auth -> subscribe -> marked wrap ->
watcher -> push attempt, and proof replay rejected.
Client half (service worker, subscribe hook + Settings toggle, marker on DM
sends) is the next step.
Rebuilds the APK with everything shipped since 1.5.9 (all previously web-only):
destination search + bigger follow map, MintRadar ratings/reliability in a mint
detail sheet, counter-offers + barter with notifications and a Trades bell tab,
the crews type control, one-close spot sheet, and the floating map toolbar.
Rebuilt the APK so the phone gets:
- voice turn prompts (device TTS) + arrival, wake lock, bigger follow map
- floating map controls (map fills the screen)
Web was already deployed; this only bumps the Android version.
Rebuilt the release APK so the phone gets what shipped this session:
- Settings -> Coordinator (pick/add/health, run-your-own hand-off)
- crew form accepts a phone-run coordinator (nprofile/cordn link) + 'Use my coordinator'
- profile discovery: publish your coordinator, one-tap add on profiles
- Edit Profile preview + 'finish your profile' checklist
Web was already deployed; this only bumps the Android version.
An OS alarm an hour before each upcoming event the user RSVP'd going or
interested to. Because the OS holds the alarm it fires with the app fully
closed — the only closed-app notification achievable without a server.
- src/lib/reminders.ts: schedule/cancel via @capacitor/local-notifications,
tracking handed-out ids in localStorage so withdrawn RSVPs and passed events
are cancelled without disturbing one-off notifications. Web is a no-op.
- src/hooks/useLocalReminders.ts: reuses the existing upcoming-events and
my-RSVPs queries; no new relay destination, no signing.
- Bridge mounts the hook only on native and only when the new 'reminders'
pref is on; unmount cancels everything (opt-out/logout).
- Skipped the Pro-expiry reminder on purpose: it would drag the heavy
useSasquatchPro query into a nudge.
Bumps to v1.5.7 (versionCode 10507); APK rebuilt.
Rebuilt the release APK so the phone gets everything currently on main:
opt-in on-device notifications (incl. the @capacitor/local-notifications
native plugin), the batched/smoother map markers, profile badges, the
'closest spot to me' Sasquatch intent, and the 'Keep the map free' support
element. Bumps only package.json (version sync script derives versionCode).
Mirrors the activity the app already computes (bell, crews, DMs, newsletter)
to the OS notification shade, with no new relay traffic and no new network
destination.
- src/lib/osNotify.ts: one delivery API. Web uses the Notification API; native
uses @capacitor/local-notifications (lazily imported, so the web bundle never
pays for it). Tap-through navigates in-app.
- src/components/OsNotificationBridge.tsx: mounted once in Layout, inert until
opted in. Per-source silent priming + a seen-id set means history is never
replayed as a flood, and it stays quiet while the app is visible (the bell
already covers that).
- DMs notify from the gift-wrap ENVELOPE only (src/hooks/useDmEnvelopes.ts,
no decryption) — so Amber is never prompted just to be told you have a
message. Preview is honestly generic.
- src/lib/notificationPrefs.ts + useNotificationPrefs: device-local, opt-in,
default off, per-category toggles. Settings -> Notifications UI.
Deliberately local-only: a fully closed app needs a server push, which is not
built (Phase 2 = on-device background runner; Phase 3 = content-free FCM).
Bump 1.5.4 -> 1.5.5 and give today's ships their own release block: offline map
tiles, photo mirror fallback, the DM 'No messages yet' fix, and free
name@skate-spots.org handles. 1.5.4's notes are restored below.
APK rebuilt: versionName 1.5.5 / versionCode 10505, and its bundle carries the
ai.skate-spots.org base.
The package.json homepage pointed at skate-spots.com — a different domain on a
different host, not this app.
Also adds a deploy guard: auto-deploy.sh now hashes exactly what would be
uploaded (dist/ + netlify/ + netlify.toml) and skips the Netlify deploy when it
is byte-identical to what is already live, so metadata-only commits (version
bumps, homepage) stop costing 15 credits.
Same proxy as netlify/functions/ai.mjs, rebuilt as a standalone Bun service on
the CVM host so AI calls stop spending Netlify compute/bandwidth credits and
the sats-paid Routstr key lives on our box.
- ai-proxy/server.ts: Bun.serve on 127.0.0.1:8787 — NIP-98 + Pro gate, model
allowlist, per-IP throttle, /api/ai + /api/ai/status. Own tsconfig
(typecheck:ai in npm test); eslint-ignored like cvm-server.
- ai-proxy/{package.json,.env.example,skatespots-ai.service,Caddyfile,DEPLOY.md}.
- scripts/deploy-ai-proxy.sh (npm run deploy:ai): scp + bun install + unit +
restart, mirroring deploy-cvm.sh.
- Client: one place decides the AI base, src/lib/sasquatchAi.ts (also the NIP-98
u tag). Defaults unchanged (/api/ai on Netlify); set VITE_SASQUATCH_AI_BASE to
move it. Fully reversible.
Deployed and verified on the host: /api/ai/status returns the live Routstr
balance. Public TLS (DNS + Caddy) is the remaining step before switching the app
over, so no client behaviour changes yet.
SkateSpots now runs its own NIP-05 registry, so a new skater gets a human
handle from minute one instead of an npub.
- netlify/functions/nip05.mjs: /.well-known/nostr.json plus
/api/nip05/{check,claim,release}, backed by Netlify Blobs (first-party,
no new vendor). Claims are NIP-98 signed and written onlyIfNew, so only
the owner can claim a name for themselves. Platform names are seeded in
code and cannot be taken or released.
- src/lib/skateNip05.ts + src/hooks/useSkateNip05.ts +
src/components/SkateNip05Input.tsx: shared validation, debounced
availability, and the claim control.
- SignupDialog: a 'SkateSpots name' field, prefilled from the display
name. Claimed best-effort before the profile publish; a failure never
blocks signup, and the profile only advertises nip05 once confirmed.
- EditProfileForm: claim a free handle from the NIP-05 field.
Verified on a draft deploy: claim -> resolves -> a second key gets 409 ->
release -> gone. tsc/eslint/101 tests green.
1.5.3 shipped at 19:22, before the DM fix, the directions UI rework and the
newsletter reader fix. Bump to 1.5.4 so those can actually reach people.
- package.json 1.5.4 (-> versionName 1.5.4 / versionCode 10504).
- release-notes.txt: new v1.5.4 section (DM persistence + parallel send/read,
bigger-map/app-like directions, underscore italics in the issue reader);
trimmed v1.5.3 back to what was actually published.
1.5.2 is already published to Zapstore (verified: org.skatespots.app@1.5.2 on
relay.zapstore.dev), so the post-publish work needs a new version.
- package.json 1.5.3 (-> versionName 1.5.3 / versionCode 10503).
- release-notes.txt: new v1.5.3 section (real per-mode routing + steps + follow
mode + choose-your-start + offline saved routes + Amber scoping); trimmed the
v1.5.2 section back to what was actually published.
Bump package.json (single source of truth → versionName 1.5.2 / versionCode
10502 via scripts/sync-android-version.mjs) and add the v1.5.2 release notes:
marketplace deck ratings + seller dashboard, working in-app directions, nav
fixes (desktop More / mobile Marketplace), the native mints fix, and the
newsletter CPU + relay fixes.
Patch bump over the unreleased 1.5.0, folding in today's mobile fixes: Mints in
the phone nav, instant Directions, and no stray signer prompt for the AI balance.
Sasquatch Pro (AI), in-app directions (transit/bike/moto/car), Barbers &
Hairstylists in The Lab, and a smoother app shell (no remounts, no stray
signer pop-ups).
- scripts/deploy-cvm.sh: backup remote server.ts -> upload -> restart -> confirm
the heartbeat, with --rollback. Host/SSH values come from the never-committed
~/.config/skatespots/cvm.env (or CVM_HOST), so no host details ever enter this
public repo.
- package.json: add `npm run deploy:cvm`.
- AGENTS.md: record that the CVM server runs on ONE external host (deploy via
deploy:cvm) and that the discovery relay, not the server, is the SPOF.
Load protection (public read-only endpoint — protects relays + this box):
- Per-pubkey sliding-window throttle and in-flight cap via inbound middleware.
The transport can't return a JSON-RPC error, so over-budget callers are dropped
and time out (logged).
- Global semaphore caps concurrent upstream relay queries.
- Short-TTL cache keyed by filter set, so repeated identical queries (e.g. a
burst of get_community_stats) don't re-hit the relays.
- All tunable via CVM_RATE_* / CVM_MAX_* / CVM_QUERY_CACHE_MS (documented in
.env.example); maps pruned by a janitor.
CI:
- cvm-server/tsconfig.json type-checks the standalone server; new
typecheck:cvm / check:cvm scripts, with typecheck:cvm wired into npm test so
the server can't rot unnoticed (it was excluded from tsc + eslint).
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.
- Regenerate lockfile so @tanstack/react-query (and the related
query-core / persist-client packages) resolve to 5.102.8, matching
package.json. The committed lockfile was stale at 5.75.1, which made
`npm ci` abort with "lock file's @tanstack/react-query@5.75.1 does not
satisfy 5.102.8" and left vitest running against a half-broken
node_modules.
- Use path.resolve(__dirname, './src/test/setup.ts') for vitest
setupFiles so the setup module is located relative to the config file
rather than the invocation cwd, fixing the "Cannot find module
.../src/test/setup.ts" resolution failure.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Two type errors (surfaced by `npm run test` / tsc --noEmit on a full
toolchain; the in-browser bundler does not run tsc, so they were latent):
- useCVMClient.ts: remove a malformed cast
`spots as typeof spots & { tags; created_at }[]` feeding
deduplicateByIdentity(). The intersection dropped pubkey/kind and broke
the reassignment to `spots`. deduplicateAddressable() already returns
the correct shape, so the cast was both wrong and unnecessary.
- queryPersister.ts: import PersistedClient / Persister from
@tanstack/query-persist-client-core (their canonical source) instead of
@tanstack/react-query-persist-client, whose `export *` re-export tsc
won't resolve for named type imports. Added the core package (5.102.8,
matching the pinned wrapper) as an explicit dependency.
Both are pre-existing issues on the branch, unrelated to the audit
remediation commits.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
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>
Prevents the android:sync script from regressing versionCode/versionName
back to 1.3.7. 1.4.4 → versionCode 10404 (above the previous 10403).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
package.json pinned nostr-tools to an exact 2.13.0 while the committed
package-lock.json resolved 2.15.0 (root spec ^2.13.0), so `npm ci` on a
local machine refused to run ("lock file's nostr-tools@2.15.0 does not
satisfy nostr-tools@2.13.0"). Relaxed the pin to ^2.13.0 to match the
lockfile and every other dependency's caret range. Cloud build verified.
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>
Self-audit of the 1.3.0–1.3.2 Citrine feature found two real issues:
1. False-positive detection (the important one). probeCitrine resolved true on
bare socket OPEN, so ANY process squatting on 127.0.0.1:4869 would be treated
as Citrine — and we'd then route reads AND published events at a non-Nostr
peer. The probe now completes a minimal Nostr handshake: open → send a bounded
REQ with a random sub id → require a protocol-shaped reply (EVENT/EOSE/CLOSED
echoing the sub id, or a NOTICE) before declaring the relay live. Non-JSON /
non-Nostr responses fall through to a clean timeout=false. A real relay
answers with EOSE in ~ms on localhost, so the happy path is unchanged.
2. Probe churn. The reachability query re-opened a fresh ws:// probe every 5 min
and on every window focus for the whole session — needless battery/wakelock,
and a localhost poke on every Amber round-trip. Now it only polls (30s) and
refetches-on-focus WHILE still waiting for Citrine to appear; once connected
it stops the background churn (a per-session local relay won't vanish
silently, and a failed read re-probes naturally).
Reviewed and left as-is (deliberate, safe): writing every kind to Citrine incl.
gift-wrapped DMs / Stash is intended on-device encrypted backup — all sensitive
kinds are NIP-44 encrypted before reaching the publish layer, so nothing
plaintext-sensitive is mirrored. Preview flag remains render-only.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The CitrineCard is Android-only (renders null off-native), so its four visual
states couldn't be reviewed without an APK build. Adds a strictly cosmetic
preview switch:
localStorage.setItem('citrine:preview', '1') // show card, real behaviour
localStorage.setItem('citrine:preview', 'live') // force connected state
localStorage.setItem('citrine:preview', 'waiting') // force not-detected
localStorage.setItem('citrine:preview', 'searching') // force searching
localStorage.removeItem('citrine:preview') // back to normal
Safety: the flag unlocks RENDERING ONLY via isCitrinePossible(). It never sets
citrine.active and never yields a relay url, so no query or publish is ever
routed anywhere different — production relay behaviour is identical with or
without the flag. A forced state is purely visual (enabled/probing are derived
locally in the card for preview; the real probe still governs active/url).
lib/citrine.ts: citrinePreview()/isCitrinePreview() + CitrinePreviewState.
useCitrine: exposes `preview`. CitrineCard: forced state overrides the computed
state for review only.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Completes the local-relay integration so it actually works end-to-end and feels
first-class.
Native (Android):
- android-templates/network_security_config.xml — permits cleartext for
LOOPBACK ONLY (127.0.0.1 / localhost / ::1); all real traffic stays TLS. This
is what lets ws://127.0.0.1 (Citrine) connect on API 28+, which otherwise
silently blocks cleartext.
- scripts/inject-network-security.mjs — idempotently copies the XML into
res/xml/ and adds android:networkSecurityConfig to <application>, mirroring
the existing inject-amber-queries pattern so it survives `cap sync`.
- Wired into android:init / android:sync / android:build.
Reads AND writes now reach Citrine app-wide:
- NostrProvider's NPool routers (reqRouter/eventRouter) fold in the active
Citrine URL via a ref, so every default-pool query hits it first (instant
local reads) and every useNostrPublish write also lands on-device (backup +
offline capture). Previously only explicit useSkateRelays paths benefited.
- A dedicated effect refreshes ['nostr'] queries when the local relay comes/goes
so reads switch over immediately. Fully no-op off-Android (url stays null).
UI/UX (Relays settings CitrineCard):
- Four crisp states — off / searching / live / waiting — with a breathing then
glowing status pip, benefit chips (Instant reads · Works offline · On-device
backup) that pop in on connect, a spinning Recheck, an actionable install CTA,
and a persistent encryption-reassurance note.
- Graceful first-run: shows "searching" until the first probe settles (via new
useCitrine.settled), never flashing the orange "not detected" state.
Tailwind: added status-breathe / status-live / pop-in keyframes+animations.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Integrates Citrine (github.com/greenart7c3/Citrine), an Android app that runs a
full Nostr relay locally on the phone. Because it's "just a relay URL", it folds
into the existing useSkateRelays / nostr.relay() machinery with no new protocol.
What it buys SkateSpots:
- Instant reads: a localhost hit (~1ms) is queried first, remote relays run in
parallel and reconcile — every skate-content surface feels faster.
- Offline-in-the-field: reads work with no signal; writes land on-device and
publish to remote when connectivity returns.
- Resilience/backup: a durable local copy of the user's own events survives
public-relay outages and addressable-event eviction.
- Privacy: local-first data can lean on the device instead of public relays.
Design / safety:
- Android-only (Capacitor native + platform === 'android'); renders nothing and
never probes localhost on iOS/desktop web. Mixed-content ws:// is already
permitted via capacitor allowMixedContent.
- Opt-in only via config.useCitrineRelay (default off). No data is ever routed
through Citrine unless the user enables it AND a relay is actually reachable.
- Reachability probe (short-lived WebSocket) cached through react-query, shared
app-wide, re-checked periodically + on focus, with a manual Recheck button.
- When active, the local URL is prepended to reads and included in writes; the
read-relay set feeds getQueryKeySuffix so caches refetch automatically when
Citrine comes/goes.
- Encryption unchanged: Stash/DMs stay NIP-44 encrypted regardless of
destination — the Settings card states this explicitly. Local ≠ plaintext.
New: src/lib/citrine.ts, src/hooks/useCitrine.ts, CitrineCard in the Relays
settings screen. AppConfig gains useCitrineRelay (zod-validated, optional).
On-device verification still needed: confirm Android permits ws://127.0.0.1
cleartext to localhost (network security config) once Citrine is running.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
NIP-17 group messages (rumor with >1 recipient p-tag) were bleeding into the
1:1 surfaces:
- useConversation (thread view): a group message from `otherPubkey` where I was
also a recipient satisfied isFromOther && isToMe, so group chatter appeared
inside the 1:1 thread.
- useConversations (list): group messages were collapsed to conversationWith =
sender (or an arbitrary recipient for my own sends), spawning phantom 1:1
entries and double-listing every group (once here, once in
useGroupConversations).
Both 1:1 hooks now skip any rumor with more than one p-tag. Groups remain the
sole responsibility of the useGroupConversation(s) hooks, which already require
pTags.length > 1. Genuine 1:1 rumors carry exactly one recipient p-tag
(createRumor doesn't tag the sender), so no legitimate message is dropped.
Verified solid (no changes): the seal(13)/rumor(14) kind checks, the
rumor.pubkey === seal.pubkey impersonation guard, and the amberSafeQueryOptions
decrypt-storm protection for interactive signers are all correct.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
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 all three remaining surfaces. Two of them needed no changes:
MARKETPLACE / GEARSWAP — sound. useMarkListingSold preserves every tag
and flips only `status` (author-gated, right comment explaining why it
doesn't rebuild). Queries validate + dedupe, exclude showcase-only Stash
items from the sellable market, and GearSwap filters out expired/sold
listings and separates WTB from WTS. No bugs.
STASH — sound, and notably careful. The private list is one NIP-44
encrypted-to-self blob; all three mutations (add/update/remove) use
fetchLatestStash() (fetch-before-write) which falls back to non-empty
cache and THROWS rather than risk clobbering the user's gear, and is
Amber-gated (staleTime: Infinity). The public/private showcase
reconciliation in StashPage is complete: going public publishes the
kind-30402 showcase event, and going private / deleting / converting to
a wishlist item all call unpublishItem — so a public event can't linger
after an item is made private (no privacy leak). No bugs.
THE LAB — the collective join-request clobber was already fixed in
1.2.37. One robustness gap remained: useLabProfile fetched the profile
with limit:1 and trusted whatever came back. Relays can hold multiple
revisions of an addressable event, so a stale revision could win —
showing an old bio or, worse, a roster missing recently-approved
members. Now fetches a few (limit:10) and reduces to the newest
created_at, matching the pickMostRecent discipline already in useCrew.
On detail pages an author filter is always set, so newest-of-one-author
is exactly right.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Audited the whole crew surface. The trust model is genuinely solid:
membership is double-sided and unforgeable (founder p-tags + the
member's own NIP-32 kind-1985 attestation, cross-referenced in
useCollectiveMembers), roles are founder-controlled via the crew's
addressable event (useCrewRole), moderation only honours labels authored
by listed moderators, and the invite/join/endorse flow is symmetric with
proper author validation and batched, deduped queries throughout.
One real bug found — a stale-snapshot member clobber. The crew roster is
a single addressable event rewritten wholesale on every member add, but
three write sites rebuilt it from the CACHED crew/profile snapshot
without re-fetching first:
• useApproveJoinRequest (founder "Add to crew")
• useSendCrewInvite (founder pre-list on invite)
• useApproveCollectiveJoinRequest (Lab collective owner add)
So approving two join-requests back to back rebuilt the second write from
the pre-first-write roster and silently DROPPED the member added by the
first (addressable events replace by coordinate). This is exactly the
fetch-before-write discipline already applied to spots/events/shops/
contacts/stash — the crew/collective roster writes were the ones missed.
Fix: added fetchLatestCrewEvent() (and an inline equivalent for Lab
profiles) that re-queries the freshest roster event by coordinate and
merges the new member into THAT, never regressing to an older revision,
falling back to the passed-in snapshot only on a relay hiccup so an add
still goes through. Founder-only / owner-only authorization and the
membership-attestation trust model are unchanged.
Member removal / admin promotion go through the founder's own profile
editor (a deliberate full-roster overwrite by the sole owner), so they
aren't concurrent-merge scenarios and are left as-is.
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>
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>
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>
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>
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>
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>
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>
Third and final surface with the encrypted-replaceable-list clobber pattern
(after Stash and Contacts). useDismissCrewNotification's cross-device sync path
built the published set from cache-or-local-mirror, which are both empty on a
fresh device before the useCrewDismissals read query resolves. A non-interactive
user dismissing a notification in that window would publish a set with only the
new id, overwriting — and wiping — the previously-synced dismissals from other
devices.
Fix: before encrypting/publishing, re-fetch + decrypt the remote dismissal set
and union it with the pending id (and keep the local mirror + cache aligned).
On read failure it still publishes the local union — dismissals are additive, so
we can only ever under-merge, never silently wipe.
Lower stakes than Stash/Contacts (worst case: a handled invite re-appears in the
bell on another device, not real data loss), but it keeps the fetch-before-write
discipline consistent across every encrypted list in the app. Amber path is
unchanged — still local-only, zero extra prompts.
Bumps to 1.2.25 (Android versionCode 10225 auto-derived).
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>
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>
Accepting/declining a crew invite (and handling join-requests) fired an
encrypted NIP-78 cross-device sync on every dismissal, costing an extra
encrypt + sign Amber approval on top of the required membership signature.
For interactive (Amber) signers, dismissal is now kept local-only:
• Accept invite → 1 Amber prompt (membership attestation only)
• Decline invite → 0 Amber prompts (local dismissal only)
The dismissal still applies instantly and permanently on-device; only the
cross-device "clear it on my other devices too" nicety is dropped for Amber.
Extension/nsec/bunker signers keep the silent encrypted sync (free for them).
Bumps to 1.2.19 (Android versionCode 10219 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>
With Amber as signer, every nip44 decrypt is a foreground intent (visible
popup). Several hooks decrypt inside auto-running queries, so Amber flashed on
navigation/focus and (worst case) storms — one popup per encrypted event, and
polling queries re-fired every few seconds.
New helper isInteractiveSigner() (signerCapabilities.ts) detects the Amber
(x-amber) signer, where each crypto op prompts the user. Hooks now gate passive
decryption for interactive signers only; extension/nsec/bunker keep their
silent, live-polling behaviour.
Fixes:
- useCrewDismissals (GLOBAL via header NotificationBell — the main offender):
Amber users now use the instant localStorage mirror only, never passively
decrypt the synced list; disabled refetchOnWindowFocus + Infinity staleTime.
Dismissals still sync OUT on the write path.
- useDirectMessages (all 4 DM query hooks): added amberSafeQueryOptions() —
no background polling and no focus refetch for Amber (these decrypt up to
~200 gift wraps × 2 each); silent signers keep the 60s poll.
- useStash / usePrivateContacts: disabled focus refetch + Infinity staleTime
for Amber so the single decrypt runs once, not on every focus.
- useCordnChat: dropped the 5s live poll for Amber (was a popup every 5s);
silent signers keep it.
Scanned every signEvent call site: all are inside mutations/user callbacks
(zaps, invites, DMs, chat, follows, purchases, NIP-98 auth) — no passive
auto-signing anywhere. Deliberate user actions still prompt Amber as intended.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Root cause: the stock nostr-signer-capacitor-plugin (0.0.5) tries Amber's
background ContentProvider first, which GrapheneOS blocks and never returns,
with no clean fallback — so signing hung 90s and Amber never appeared.
Fix (matches Amethyst, the reference NIP-55 client):
- New native Capacitor plugin AmberIntent (android-templates/AmberIntentPlugin.java)
that goes STRAIGHT to a foreground nostrsigner: intent via a properly-registered
ActivityResultLauncher, tracks each request by id, and times out gracefully.
- MainActivity now registers the launcher in onCreate (AndroidX contract) and
hands it to the plugin, while staying a safe stock BridgeActivity (no
WebViewClient override → no black screen).
- inject-main-activity.mjs now installs both MainActivity.java and AmberIntentPlugin.java.
- capacitorAmberSigner.ts rewired to registerPlugin('AmberIntent'); every op is a
user-visible Amber Approve/Reject popup; rejections surface as a friendly message.
- Removed the dead npm nostr-signer-capacitor-plugin dependency (and its phantom
coracle fork spec) plus its vite external entry — signing no longer uses it.
- Delete flow copy updated to the new foreground UX (Approve in Amber / Cancelled
on reject) instead of the diagnostic SIGN/PUBLISH narration.
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>
We can't identify where delete freezes. Split the flow into two independently
timed, on-screen-narrated phases: Step 1 SIGN (Amber) and Step 2 PUBLISH
(relays). Whichever toast is on screen when it hangs tells us exactly which layer
is at fault — Amber vs relay — so we fix the right thing. Bump to 1.2.13.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Admin delete fired THREE signed events (kind:5 deletion, kind:1985 label, NIP-86
banevent). Each opens Amber, but Amber handles one approval at a time — the
extra background signs collided with the primary one on Android and hung the
whole delete ("taking longer than expected"). Now the admin path publishes only
the kind:5 deletion (one Amber approval) and hides locally (no signature); the
optional label/relay-ban are dropped from the interactive flow. Also raise the
awaited timeout 40s→120s to fit a real Amber round-trip. Bump to 1.2.12.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The manifest launches org.skatespots.app.MainActivity; when that class was
missing/wrong the app crashed on startup. Add a STOCK MainActivity (extends
BridgeActivity, no WebViewClient override) plus an idempotent injector that
guarantees it's always present after cap add / rm -rf android. This is the safe
default — the earlier custom version broke startup. Delete fix (setPackageName)
remains intact. Bump to 1.2.11.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The injected MainActivity overrode Capacitor's WebViewClient, wiping out the
client Capacitor needs to load the web layer — causing a black-screen crash on
launch. Remove the MainActivity template, its injection script, and the build
wiring. The real signing fix (setPackageName before signEvent) is unaffected and
remains in place. Bump to 1.2.10.
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>
Amber never appeared for delete/accept-invite/zap because Capacitor's default
WebView silently drops JS-initiated intent:/nostrsigner: navigations — the
request that opens Amber never reached the OS. Login worked only because it
uses the plugin's native startActivityForResult.
Add a custom MainActivity (android-templates/MainActivity.java) that overrides
shouldOverrideUrlLoading to launch intent:/nostrsigner: URLs via startActivity()
— a real native launch that opens Amber on GrapheneOS, matching login. Injected
into the generated android/ project at build time by inject-main-activity.mjs,
wired into android:build/sync/init. Simplify the JS launcher to navigate the
WebView (now intercepted natively). Bump to 1.2.8.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Pinning exact versions produced a broken @nostrify/nostrify variant on
some installs (raw .ts source missing @std/encoding / websocket-ts deps).
Revert to caret ranges so npm resolves the complete, working tree.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The clean node_modules reinstall pulled newer @nostrify/* and nostr-tools
minors whose nip57.makeZapRequest signature changed (event param became a
required full NostrEvent), breaking tsc in useZaps and usePurchasePro.
Build the kind:9734 zap request events directly per NIP-57 instead of
relying on nip57.makeZapRequest, so we're decoupled from that helper's
changing type signature. Preserves correct target tagging:
- addressable events → 'a' tag (kind:pubkey:dTag)
- other events → 'e' tag (event id)
- profile-only (pro purchase) → no event tag, keeps pro-tier/pro-expiry.
Pin @nostrify/nostrify, @nostrify/react and nostr-tools so the tree
stays reproducible.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- useGrowthStats now queries and exposes every Lab craft individually: artists,
graffiti, tattoo, trainers, musicians and judges (previously folded into an
opaque "other" bucket with no visible breakdown).
- CommunityStatsPage renders a card for all 11 Lab crafts, so the section matches
what /lab actually lists instead of only showing 5 categories.
- labTotal + the content-mix donut's "Lab" slice now sum every craft, keeping the
"total" badge and chart consistent.
Note on relay mismatch: the app dedupes addressable events (keeps newest per
pubkey+kind+d) and merges across the community relay + the user's relays, so
counts can legitimately differ from a raw single-relay browser that shows stale
duplicate versions.
Bump version to 1.2.1 (versionCode 10201).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
1. Map filter: renamed the "Bitcoin" toggle to "Spedn" (matches the toolbar
button); tooltip now says "Spedn your sats".
2. Community stats (/stats): useGrowthStats now queries the same relay set as the
rest of the app (community relay + user relays) via useSkateRelays instead of
only the single hardcoded SKATE_RELAY. Lab profiles / spots / etc. that live on
a user's own relay are now counted, so the numbers match what /lab shows.
3. Near Me on GrapheneOS: even after granting the OS location permission, the
WebView's navigator.geolocation stays blocked → "permission denied". Fixed by
fetching the position from the native Capacitor Geolocation plugin first
(LocationManager, honours the granted permission), falling back to
navigator.geolocation only if that fails. Also auto-inject
ACCESS_FINE/COARSE_LOCATION into AndroidManifest via the manifest script.
Bump version to 1.2.0 (versionCode 10200).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Two concrete root causes for the eternal "Waiting for Amber…":
1. setPackageName could hang forever on GrapheneOS. getPlugin() awaited it with
no timeout, so getPublicKey() (and its 90s timeout) was never even reached.
Now setPackageName races a 3s timeout and continues regardless — the Amber
package is passed explicitly on every call anyway, so it's non-essential.
2. AndroidManifest.xml had no <queries> block. On Android 11+/GrapheneOS the OS
silently blocks the nostrsigner intent without it, so Amber never opens. Added
scripts/inject-amber-queries.mjs (idempotent) and wired it into android:init /
android:sync / android:build so the block is guaranteed present in every APK,
surviving cap sync regeneration.
Bump version to 1.1.9.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Root cause: LoginDialog and useCurrentUser detected the native app via
window.Capacitor, which is unreliable inside the WebView (false at mount / not
attached in some builds). So the ZapStore APK fell into the web intent:// redirect
flow and never opened Amber ("Waiting for Amber…" hang).
Fix: add isNativeAmberAvailable() in capacitorAmberSigner.ts using the imported
@capacitor/core Capacitor + native plugin presence, and use it (evaluated at
click time) in LoginDialog.handleAmberLogin and in useCurrentUser's signer
selection. The APK now calls getAmberPublicKeyViaPlugin() → native intent → Amber
opens directly, no redirects.
Bump version to 1.1.7.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
1.1.2 maps to versionCode 10102 via the sync-android-version scheme.
Release notes lead with the Lab craft-filter mobile grid fix, keeping the
1.1.1 highlights since this is the release carrying all of it to Zapstore.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Single source of truth is package.json (scripts/sync-android-version.mjs
derives versionName/versionCode from it at android build time). 1.1.1 maps
to versionCode 10101 via the major*10000+minor*100+patch scheme.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
1.0.92 was already published to Zapstore, so move to 1.0.93. Adjusted
sync-android-version.mjs so 1.0.x maps versionCode = patch (1.0.93 -> 93),
continuing the existing published scheme instead of jumping to 10093.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Adds scripts/sync-android-version.mjs which writes versionName (semver) and a
monotonic versionCode (major*10000+minor*100+patch) into android/app/build.gradle
from package.json. Wired into `npm run android:build` and `android:sync`, plus a
standalone `npm run android:version`. Now you bump ONE number (package.json) and
never hit Zapstore's "already exists" again. Exits quietly if android/ isn't
generated yet.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Local android/ build reported 1.0.91 which is already published, so bump the
repo to 1.0.92 to stay consistent. Note: android/app/build.gradle versionCode/
versionName must be bumped locally too (cap sync does not propagate it from
package.json).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Fixes 'npx cap: could not determine executable to run' and the Gradle
'No variants exist for :capacitor-android' error by tracking the Capacitor
build tooling in package.json so it survives npm install.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Bug: after taking a Quick Snap photo and choosing "keep", the user never
reached the Add Spot form. handleQuickCameraCapture uploaded the photo to
Blossom FIRST (which needs an Amber signature and can be slow/fail) and only
navigated on success — so a stalled upload stranded the user right after the
camera.
Fix: Quick Snap now prioritizes speed —
- MapPage stashes the raw photo as a data URL (quickCapturePendingPhoto),
grabs location (non-blocking), and navigates to /create-spot immediately.
- CreateSpotPage reads the pending photo on mount, converts it back to a File,
and uploads it in the background with an "Uploading photo…" tile, adding the
resulting URL to the images list. A failed upload now shows a toast on the
form instead of blocking navigation. Old quickCaptureImage (already-uploaded
URL) path kept for backward-compat.
- Removed the now-unused useUploadFile from MapPage.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The native Geolocation fix only takes effect once the APK is rebuilt — the
current phone build still uses navigator.geolocation, hence the error. To make
the rebuilt version more robust:
- If a high-accuracy fix fails/times out (common indoors or weak GPS), retry
with a coarse (network/wifi) fix and a longer window before giving up. This
rescues most "Location unavailable" cases.
- On native, the "unavailable" toast now tells the user to turn on Location/GPS
AND allow the app permission, instead of the generic web message.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Symptom: crew logo upload spins forever, and crew creation appears to do
nothing. Both go through Amber signEvent. Login (getPublicKey) works because
it uses Amber's silent content-resolver path, but signEvent must launch
Amber's approval Activity and wait for the result — and if that result never
comes back (buggy plugin build, user backs out, missing permission) the
bridge promise never settles, so the UI hangs with no error.
Changes:
- withTimeout() wraps every Amber bridge call (sign/getPublicKey/nip04/nip44)
with a 90s cap. A hung round-trip now rejects with a clear, actionable
message instead of spinning forever, which also unblocks the CrewForm
(isUploading/isPublishing finally resolve).
- setPackageName is now called ONCE per session (cached promise) instead of
before every op — repeated calls are needless bridge chatter and can wedge
on some builds.
NOTE (root cause, fixed on the build machine): package.json requests the
GitHub `main` build of nostr-signer-capacitor-plugin, but package-lock.json
still pins the old npm 0.0.5 (which the APK bundles). 0.0.5 is the version
prone to the signEvent activity-result hang. Regenerate the lockfile with
`npm install` so the `main` build is used — see chat for steps.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
1. Near Me "location blocked" on the APK:
- navigator.geolocation returns PERMISSION_DENIED inside the Android WebView
because the WebView never gets the OS location permission, so the app
showed a bogus "your browser blocked location" help sheet.
- New src/lib/geolocation.ts: platform-aware getCurrentPosition(). On native
it uses Capacitor's Geolocation plugin via registerPlugin('Geolocation')
from bundled @capacitor/core (same runtime pattern as the camera/Amber
fixes) and calls requestPermissions() to trigger the real Android prompt;
on web it falls back to navigator.geolocation.
- MapPage handleNearMe, proximity sort, and quick-snap location now use it.
On native, a denied permission shows an OS-permission toast instead of the
browser help sheet.
- Added @capacitor/geolocation dependency (native side picked up via cap sync;
no bare import in the JS bundle).
2. Desktop map didn't fit the viewport (had to scroll to see footer):
- Map wrapper was md:h-[calc(100vh-4rem)] — full height minus header only,
leaving no room for the footer.
- Added --footer-desktop token (5rem) and changed the desktop height to
calc(100vh-4rem-var(--footer-desktop)) so map + footer fit without scroll.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- New useDeleteEntity hook: publishes a kind:5 deletion request referencing
the target addressable event by `a` coord, `e` id, and `k` kind. Owner-only.
- New reusable DeleteEntityDialog: owner-gated trigger + confirmation with a
clear "deletion is best-effort on decentralized relays" warning, redirect,
and query invalidation so the item drops out of lists.
- Wired "Disband" into CrewDetailPage (founder only, redirects to /crews).
- Wired a "Manage → Delete Profile" card into CoachDetailPage for all Lab
profile types (coach/filmer/editor/photographer/builder), redirects to /lab.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Root cause (same class as the camera bug): capacitorAmberSigner.ts used
`await import('nostr-signer-capacitor-plugin')`, but that package is marked
external in vite.config.ts and the native build uses inlineDynamicImports, so
the bare specifier survives in the APK bundle and the Android WebView can't
resolve it at runtime. The import threw, getPlugin() returned null, and every
Amber signing op — including the Blossom upload auth event that a Crew logo
upload needs — failed with "Capacitor Amber plugin not available".
Fix: access the native signer via Capacitor's registerPlugin('NostrSignerPlugin')
from the bundled @capacitor/core, and reimplement the plugin's thin JS wrapper
logic directly against the native bridge (options-object API per the plugin's
main source). This removes the broken dynamic import entirely and is version-
independent as long as the native plugin is registered. Covers both native
Amber login (getAmberPublicKeyViaPlugin) and all signing/encryption paths.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Root cause: quickCapture.ts relied on window.Capacitor.Plugins.Camera,
which is only populated once something imports '@capacitor/camera'. Since
we deliberately never import that package (to avoid the old white-screen
crash), the plugin was never registered, the native check failed, and Quick
Snap fell through to the web file input — opening the gallery.
Fix: use Capacitor's official registerPlugin('Camera') from the bundled
@capacitor/core. This returns a proxy that dispatches to the native Camera
implementation over the bridge without needing the JS @capacitor/camera
package to self-register, and without putting a bare '@capacitor/camera'
specifier in the bundle. Applied the same pattern to CameraCapture.tsx.
Also: on a permission error, show a clear "enable camera access" toast
instead of silently opening the gallery.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The previous file-input-only approach fixed the crash but opened the gallery
on native because capture="environment" is only a hint the OS can ignore.
New platform-aware flow (lib/quickCapture.ts):
- Native (APK): open the OS camera directly via the runtime-injected
window.Capacitor.Plugins.Camera with source:'CAMERA' — forces the real
camera and gives built-in preview/retake. Accessed off the runtime object,
so no '@capacitor/camera' specifier in the bundle (keeps the white-screen
fix intact). User cancel is treated as a no-op.
- Web: falls back to the file input with capture="environment".
- If the native camera errors, gracefully falls back to the file input.
MapPage Quick Snap now routes through handleQuickSnap() → the captured photo
feeds the existing upload → create-spot flow unchanged. Bump 1.0.69.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Phase 0 — identity (lib/crewAgent.ts):
- Deterministic per-crew agent keypair derived from the crew coordinate
(sha256 of a public salt + coord). Documented security tradeoff: the key
is reproducible by design and holds no funds — used only for the agent to
speak for itself. Includes kind-0 profile builder (bot:true, NIP-24) and
a signAsCrewAgent helper for Phase 3.
Phase 1 — opt-in toggle:
- SkateCrew.agentEnabled parsed from a queryable ["t","crew-agent"] marker.
- "Crew Agent" toggle (Beta) in CrewForm; publishes the marker when on.
- NIP.md documents the crew-agent tag and the agent identity derivation.
Phase 2 — crew-scoped Sasquatch with receipts:
- lib/sasquatchBrain.ts: shared AI caller (model fallback) + tool-call parser.
- useCrewAgentChat: reuses the CVM data tools, seeded with crew context
(name/location) so "near us" resolves to the crew's turf. Ephemeral/local,
no keys, no publishing yet.
- CrewAgentPanel: chat dialog rendering agent answers PLUS structured receipt
cards (spots / events / Lab pros / gear / crews) linking into the app.
- CrewDetailPage: "Ask Sasquatch" card + panel, shown only when the crew
enabled the agent.
Also: Lab category tabs → fixed 5-across grid (no horizontal scroll). Bump 1.0.68.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Local CAPACITOR_BUILD failed: Rollup couldn't resolve import
"@capacitor/camera" from CameraCapture.tsx — the package is listed in
package.json but isn't present in node_modules on the build machine.
Root pattern (same class as the @capacitor/core crash): a package that
must NOT be resolved at build time was being imported by specifier.
Fix:
- CameraCapture.tsx no longer imports '@capacitor/camera' at all. The
Camera plugin is accessed via the runtime-injected
window.Capacitor.Plugins.Camera (Capacitor injects it natively), using
string enum values ('dataUrl' / 'CAMERA'). No bare specifier remains in
the bundle, and it degrades gracefully if the plugin is absent.
- vite.config: @capacitor/camera moved back to `external` (belt-and-braces;
it's no longer imported anyway). @capacitor/core stays BUNDLED (static
import in platform.ts — the white-screen fix). nostr-signer-capacitor-plugin
and the 4 uninstalled @capacitor/* plugins remain external.
Verified: no @capacitor/camera specifier and no unresolved @capacitor/core
import in the built bundle; no static imports of any externalized package
remain in src/. Bump to 1.0.67.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
v1.0.65 fixed the runtime crash (bundling @capacitor/core) but broke the
CAPACITOR_BUILD by also un-externalizing nostr-signer-capacitor-plugin —
which is installed from GitHub with a broken package entry point and
cannot be bundled ("Failed to resolve entry for package").
Correct split:
- BUNDLE @capacitor/core & @capacitor/camera (installed, statically
imported, real JS the WebView needs) — this is the white-screen fix.
- KEEP EXTERNAL nostr-signer-capacitor-plugin — no valid entry point;
only used via a guarded dynamic import() at runtime on native
(try/catch → null on failure), so external is correct and safe.
- KEEP EXTERNAL the four non-installed @capacitor/* plugins.
Verified: built bundle has no bare static @capacitor/core specifier;
signer plugin remains an external dynamic import. Bump to 1.0.66.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The boot crash guard surfaced the real root cause on-device:
TypeError: Failed to resolve module specifier "@capacitor/core"
@capacitor/core and @capacitor/camera were marked `external` in the
Rollup config, which left bare `import "@capacitor/core"` specifiers in
the output bundle. A native WebView has no module resolver for bare
specifiers, so boot crashed before React mounted.
Fix: remove the INSTALLED Capacitor packages from `external` so their JS
API layer is bundled (the native implementation is still injected at
runtime via window.Capacitor). Only the four NON-installed packages
(@capacitor/app, haptics, keyboard, status-bar) stay external — they are
never statically imported (accessed via window.Capacitor.Plugins), so
this can't break the build.
Bump to 1.0.65.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Add @capacitor/core + src/lib/platform.ts (isNativePlatform/getPlatform, web-safe)
- mintReachability: on native (no CORS jail) short-circuit probe to 'ok' so ANY mint works, including mint.minibits.cash
- useCashu default mint: native prefers minibits (matches skatespots@minibits.cash brand identity) then coinos/21mint; browser uses CORS-friendly candidates
- MintPrivacyManager recommended list is platform-aware: native leads with Minibits (recommended), web leads with Coinos and flags Minibits as native-only
- CashuWallet: 'This mint can't be reached from your browser' banner for the browser CORS case (never shows on native), pointing users to a web-friendly mint or the native app
- Lightning invoice error mentions the native-app option
Same codebase now uses minibits directly when running as the native app, and a CORS-safe mint when running in a web browser.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Architecture:
- usePlatformFee hook: central fee config (featuredEvent/labProfile/featuredShop)
5,000 sats each, 30-day validity, localStorage persistence, owner bypass
- PlatformFeePayment component: generic reusable payment gate
auto-pay via NWC/WebLN, QR fallback, active/expired state display
all fees route to skatespots@minibits.cash
Events (CreateEventPage):
- New optional "Entry Fee" field
- Triggers 5,000 sat platform fee when filled
- Stores entry-fee + paid tags on published event
- Free events: zero friction unchanged
The Lab (Become{Coach,Filmer,Editor}Page):
- Triggers 5,000 sat fee when "Rate" field is filled
- Submit disabled with clear CTA until fee paid
- Free/volunteer profiles: zero friction unchanged
Rule: "pay only when you earn"
- No rate set → publish instantly, no fee
- Rate set → 5,000 sats to SkateSpots, valid 30 days
- Platform owners always bypass
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Toolbar:
- Slim rounded-pill search input (was tall boxy default)
- Compact icon-only view toggle pills
- Small circle filter button (red when active, badge count)
Right-side control strip:
- Layer toggles (Spots/Shops/Gear/Tours) as 36px circles
active=opaque, inactive=35% opacity — no more giant emoji FABs
- Near Me button integrated into strip (removed from top-right)
- Quick Snap in strip, same style — no more big red circle
- All transitions: translate-x-2 slide-out when overlays open
Left-side Add FAB:
- Single gradient pill (red→orange) replaces two competing circles
- Smaller footprint, less visual noise
Loading indicator:
- Replaced center-screen "Loading..." pill with slim animated
gradient bar at top edge of map (top-0 left-0 right-0 h-0.5)
Attribution:
- Moved from floating top-right to bottom-left corner
- 9px text, 50% opacity — required but invisible
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
## 1. Map as Homepage
- `/` now renders MapPage (the globe/map) — removed old Index homepage
- `/home` redirects to Index (legacy), `/map` alias still works
- Navbar: removed HOME link, SPOTS (MapPin) is now first nav item
- isActive() treats both `/` and `/map` as active for SPOTS tab
## 2. 3D Globe View
- New SkateGlobe component using pure Three.js (no h3-js/CDN issues)
- Earth night texture with atmospheric neon red glow
- Spot pins (red) + shop pins (lime) on globe surface
- Click pin → zoom camera in + show info card
- Drag to rotate, scroll/pinch to zoom, auto-rotation when idle
- Globe toggle button added to map/list view switcher
- Selected marker ring + info card with View Spot/Shop links
- Lazy-loaded with Suspense skeleton
## 3. Settings → Stash Link
- Added My Stash card in Settings > Wallet tab
- Links to /stash with Package icon and description
- Smooth hover animation with ChevronRight
## 4. Camera Fix (Capacitor Native)
- CameraCapture now detects native Capacitor platform at runtime
- On native Android/iOS: uses @capacitor/camera (OS-level camera, no WebView permission popup)
- On browser: falls back to getUserMedia stream (existing behaviour)
- Added Upload from Gallery fallback when camera permission denied
- Better error handling for user cancellation
## 5. Sasquatch Upgrades
- FAQ page: replaced static accordion wall with Sasquatch-powered UI
- Quick question chips that pre-load questions into Sasquatch
- "Open The Sasquatch" CTA button
- Quick facts grid, clean modern design
- About page: added Sasquatch CTA card at bottom
- SasquatchGuide: listens for sasquatch:open and sasquatch:ask custom events
- External pages can now trigger and pre-fill the Sasquatch
- Sasquatch now shows even without a page-level message prop (for external triggers)
## 6. UI/UX Audit & Polish
- Focus rings: on-brand red (WCAG accessible)
- Card hover micro-interactions: subtle translateY + shadow
- Button/link transition consistency (150ms ease)
- Reduced motion support (@media prefers-reduced-motion)
- Modal backdrop blur improvement
- Skeleton shimmer animation utility
- Globe CSS utilities (touch-action: none for canvas)
- Typography helpers (text-balance, text-pretty)
- Improved map-full-height responsive calculation
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The chebizarro npm version has a different API to the Coracle fork that
Welshman/Coracle actually use in production. Now using:
github:coracle-social/nostr-signer-capacitor-plugin#main
Correct API (from Welshman nip55.js source):
NostrSignerPlugin.setPackageName(packageName: string) // string not object
NostrSignerPlugin.getPublicKey(packageName?: string) // packageName as arg
NostrSignerPlugin.isExternalSignerInstalled(packageName?)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
All previous URL-scheme approaches (nostrsigner:, intent:) were fundamentally
wrong for a Capacitor app. JavaScript in a WebView cannot reliably dispatch
Android intents via URL navigation.
The correct solution — used by Coracle and other Capacitor-based Nostr apps —
is the nostr-signer-capacitor-plugin which communicates with Amber via native
Android intents through the Capacitor bridge:
NostrSignerPlugin.setPackageName({ packageName: 'com.greenart7c3.nostrsigner' })
NostrSignerPlugin.isExternalSignerInstalled()
NostrSignerPlugin.getPublicKey() → { npub }
This is synchronous from the JS side, handles the entire Android intent
roundtrip natively, and returns the pubkey directly. No URL encoding,
no callback URLs, no redirects, no malformed request errors.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
GrapheneOS Vanadium browser blocks intent: URLs from web pages as a
security hardening measure. This is why every URL format we tried
caused "malformed request" — Vanadium was blocking the intent before
Amber ever saw it.
New flow that works on ALL Android browsers including GrapheneOS:
1. Tap "Open Amber" → navigates to nostrsigner: URL with no callbackUrl
(Amber copies pubkey to clipboard when no callbackUrl is given)
2. User approves in Amber, comes back to app
3. App tries navigator.clipboard.readText() automatically on visibilitychange
4. If clipboard permission denied → shows paste input for hex pubkey
5. User pastes pubkey → logged in
No intent: scheme, no callback URL, no redirect loop. Simple and bulletproof.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Three compounding bugs fixed:
1. WRONG FORMAT: Was using nostrsigner: directly, then intent: with encoded
values. Amber README shows intent: with raw unencoded S.key=value params.
2. # IN CALLBACKURL: Our callbackUrl was https://.../#/amber-callback which
contains a # — but intent: uses # as its own delimiter (#Intent;...;end).
Having a # inside the callbackUrl value breaks the entire intent string.
Fixed: callbackUrl is now https://.../amber-callback?event= (no hash).
3. HASHROUTER MISMATCH: Amber opens /amber-callback (no hash) but HashRouter
only handles /#/route paths. Fixed in main.tsx: pre-React IIFE detects
/amber-callback path, saves ?event= to sessionStorage, then redirects to
/#/amber-callback so React Router matches AmberCallbackPage correctly.
Final correct intent URL format:
intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;S.callbackUrl=https://skate-spots.org/amber-callback?event=;end
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Root cause of "malformed request":
- buildIntentUrl() was calling encodeURIComponent() on ALL values including
callbackUrl, which was ALREADY a full URL with special chars
- This double-encoded the callbackUrl, arriving at Amber as garbage
- The intent: wrapper format was also unnecessary
Fix: back to clean nostrsigner: format per NIP-55 spec
- Only callbackUrl gets encodeURIComponent (once)
- All other params are plain query string values
- Matches exactly what NIP-55 spec shows in its example code
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- release_notes must be a file path for zsp, not inline YAML text
- add release-notes.txt with v1.0.26 changelog
- sync package.json version to 1.0.26 to match APK versionName
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Remove debug red banner from index.html and main.tsx
- Add android:build:apk script that uses CAPACITOR_BUILD=1 for single
bundle output + strips Shakespeare build artifacts from dist/index.html
- Update all android:* scripts to use android:build:apk
Root cause of white screen: 100+ JS chunk files overwhelm Android
WebView local file serving. Single bundle with inlineDynamicImports
fixes this.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Install @fontsource/bebas-neue and @fontsource/barlow-condensed
- Add .font-display (Bebas Neue) and .font-condensed (Barlow Condensed) CSS utilities
- Hero headline: split into two lines SKATE / SPOTS with clamp() fluid sizing,
Bebas Neue replaces Impact throughout
- Subtitle: Barlow Condensed 700 with wide letter-spacing
- Description: Barlow Condensed italic
- All section headings (Featured Spots, Events, The Lab, CTA, New Here?): Bebas Neue
- Section subheadings: Barlow Condensed with tracked uppercase
- Desktop + mobile header logo: Bebas Neue
- Footer logo: Bebas Neue
- Tagline badge: Barlow Condensed 600
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The root cause of white/blank print: browsers strip CSS background gradients
and colors by default even with print-color-adjust: exact, especially inside
nested components wrapped by Layout.
Solution: use html2canvas to render the live card DOM to a canvas at 4x scale,
convert to PNG, then open a dedicated print window containing ONLY that image.
Images always print in full color — no CSS override needed.
Changes:
- Add html2canvas dependency
- renderCardToImage(): captures card DOM element at 4x scale with useCORS for
cross-origin avatars, temporarily strips holo animation for clean capture
- handlePrint(): calls renderCardToImage, opens popup window with CSS @page
set to 85.6×54mm, injects the PNG, auto-triggers print dialog
- handleDownload(): same render pipeline, saves as PNG file (for sharing
digitally, WhatsApp, iMessage, or taking to a print shop)
- Added Download / Save PNG button alongside Print Card button
- Added Loader2 spinner on buttons while rendering
- Removed print-only-card div and broken @media print CSS (no longer needed)
- Clean up CSS print block to minimal fallback only
- Fix "on-chain proof" → "Signed proof" in badge dialog
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The shadcn/ui components were using the new "radix-ui" umbrella package
import style (e.g. `import { Tooltip as TooltipPrimitive } from "radix-ui"`).
However, radix-ui@1.5.0 re-exports ALL radix primitives including ones not
installed (@radix-ui/react-accessible-icon, @radix-ui/react-form,
@radix-ui/react-one-time-password-field, @radix-ui/react-password-toggle-field,
@radix-ui/react-toolbar), causing esbuild to fail with 5 unresolved import
errors outside Shakespeare.
Fix: rewrite all 32 imports across the ui/ components to import directly
from their individual @radix-ui/react-* packages (which are all already
installed). Remove the radix-ui umbrella package entirely from package.json
and package-lock.json.
Affected files (32 import changes):
accordion, alert-dialog, aspect-ratio, avatar, badge, breadcrumb, button,
checkbox, collapsible, context-menu, dialog, dropdown-menu, form, hover-card,
label, menubar, navigation-menu, popover, progress, radio-group, scroll-area,
select, separator, sheet, sidebar, slider, switch, tabs, toggle-group, toggle,
tooltip
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
All shadcn/ui components in src/components/ui/ were already written against
the new shadcn convention of importing from the "radix-ui" umbrella package
(e.g. `import { Tooltip as TooltipPrimitive } from "radix-ui"`), but
"radix-ui" was never declared in package.json — only the individual
@radix-ui/* packages were listed.
This caused `npm i` + `vite build` to fail outside Shakespeare with:
[plugin:vite:import-analysis] Failed to resolve import "radix-ui"
Fix: add `"radix-ui": "^1.0.1"` to dependencies (resolved to 1.5.0 in
the lock file). No code changes needed.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
react-day-picker@8 requires date-fns@^2||^3, which conflicts with the
project's date-fns@^4.1.0. react-day-picker@9 dropped the date-fns peer
dependency entirely (bundles its own date helpers), so `npm i` now resolves
cleanly without --legacy-peer-deps.
- package.json: ^8.10.1 → ^9.0.0
- package-lock.json: updated version/resolved/integrity/peerDependencies
for node_modules/react-day-picker to 9.14.0
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Change z.url() to z.string().url() in AppProvider.tsx for Zod compatibility
- Update Zod version from ^3.25.71 to ^3.23.8 (valid published version)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Update Zod version from ^3.25.71 to ^3.23.8 (valid published version)
- Replace z.url() with z.string().url() in AppProvider.tsx for compatibility
- Remove unused npubcash-sdk dependency
- Remove duplicate nested src/src directory that was incorrectly created
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>