Full self-audit of the Citrine feature. Fixes found:
Robustness
- Probe now tries BOTH ws://127.0.0.1 and ws://localhost and routes to whichever
actually answers (probeCitrineEndpoints + CitrineProbeResult.url). We no longer
bet the whole feature on one loopback address form; useCitrine.url reflects the
winning endpoint so reads/writes go to the right place.
Correctness
- Preview toggle was mutating the REAL setting while in dev-preview; handleToggle
is now a no-op under preview (forced state owns the visuals).
Accessibility / "flawless" bar
- Respect prefers-reduced-motion: status pips, chip pop-in and the expand slide
now motion-reduce:animate-none (no looping glow/pulse for reduced-motion users).
- Recheck button gets an aria-label; status text is role="status" aria-live so
screen readers announce searching→detected transitions; decorative pips are
aria-hidden.
UX copy
- "Waiting" state no longer presumes Citrine isn't installed. Clearer two-step
guidance (Open Citrine → Start; come back → Recheck) covering the common
"installed but not started/detected yet" case.
Honest note: MIXED_CONTENT_ALWAYS_ALLOW (1.3.5) is the high-probability fix for
the 0-connections block but is not device-verified from here; the dual-endpoint
probe hedges the second most likely cause (address form).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Citrine is running (listening on ws://127.0.0.1:4869) but the app can't connect
and shows 0 connections — a WebView-layer block, not our logic. To diagnose
without adb/USB, the probe now returns { ok, reason } capturing which stage
failed (blocked / connect-error / timeout-no-open / opened-but-no-nostr-reply /
closed-before-open with code), and the Settings card shows a small debug readout
(probe url + reason) whenever not connected. Purely additive; no behaviour
change to routing.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Self-audit of the 1.3.0–1.3.2 Citrine feature found two real issues:
1. False-positive detection (the important one). probeCitrine resolved true on
bare socket OPEN, so ANY process squatting on 127.0.0.1:4869 would be treated
as Citrine — and we'd then route reads AND published events at a non-Nostr
peer. The probe now completes a minimal Nostr handshake: open → send a bounded
REQ with a random sub id → require a protocol-shaped reply (EVENT/EOSE/CLOSED
echoing the sub id, or a NOTICE) before declaring the relay live. Non-JSON /
non-Nostr responses fall through to a clean timeout=false. A real relay
answers with EOSE in ~ms on localhost, so the happy path is unchanged.
2. Probe churn. The reachability query re-opened a fresh ws:// probe every 5 min
and on every window focus for the whole session — needless battery/wakelock,
and a localhost poke on every Amber round-trip. Now it only polls (30s) and
refetches-on-focus WHILE still waiting for Citrine to appear; once connected
it stops the background churn (a per-session local relay won't vanish
silently, and a failed read re-probes naturally).
Reviewed and left as-is (deliberate, safe): writing every kind to Citrine incl.
gift-wrapped DMs / Stash is intended on-device encrypted backup — all sensitive
kinds are NIP-44 encrypted before reaching the publish layer, so nothing
plaintext-sensitive is mirrored. Preview flag remains render-only.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
The CitrineCard is Android-only (renders null off-native), so its four visual
states couldn't be reviewed without an APK build. Adds a strictly cosmetic
preview switch:
localStorage.setItem('citrine:preview', '1') // show card, real behaviour
localStorage.setItem('citrine:preview', 'live') // force connected state
localStorage.setItem('citrine:preview', 'waiting') // force not-detected
localStorage.setItem('citrine:preview', 'searching') // force searching
localStorage.removeItem('citrine:preview') // back to normal
Safety: the flag unlocks RENDERING ONLY via isCitrinePossible(). It never sets
citrine.active and never yields a relay url, so no query or publish is ever
routed anywhere different — production relay behaviour is identical with or
without the flag. A forced state is purely visual (enabled/probing are derived
locally in the card for preview; the real probe still governs active/url).
lib/citrine.ts: citrinePreview()/isCitrinePreview() + CitrinePreviewState.
useCitrine: exposes `preview`. CitrineCard: forced state overrides the computed
state for review only.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Completes the local-relay integration so it actually works end-to-end and feels
first-class.
Native (Android):
- android-templates/network_security_config.xml — permits cleartext for
LOOPBACK ONLY (127.0.0.1 / localhost / ::1); all real traffic stays TLS. This
is what lets ws://127.0.0.1 (Citrine) connect on API 28+, which otherwise
silently blocks cleartext.
- scripts/inject-network-security.mjs — idempotently copies the XML into
res/xml/ and adds android:networkSecurityConfig to <application>, mirroring
the existing inject-amber-queries pattern so it survives `cap sync`.
- Wired into android:init / android:sync / android:build.
Reads AND writes now reach Citrine app-wide:
- NostrProvider's NPool routers (reqRouter/eventRouter) fold in the active
Citrine URL via a ref, so every default-pool query hits it first (instant
local reads) and every useNostrPublish write also lands on-device (backup +
offline capture). Previously only explicit useSkateRelays paths benefited.
- A dedicated effect refreshes ['nostr'] queries when the local relay comes/goes
so reads switch over immediately. Fully no-op off-Android (url stays null).
UI/UX (Relays settings CitrineCard):
- Four crisp states — off / searching / live / waiting — with a breathing then
glowing status pip, benefit chips (Instant reads · Works offline · On-device
backup) that pop in on connect, a spinning Recheck, an actionable install CTA,
and a persistent encryption-reassurance note.
- Graceful first-run: shows "searching" until the first probe settles (via new
useCitrine.settled), never flashing the orange "not detected" state.
Tailwind: added status-breathe / status-live / pop-in keyframes+animations.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Integrates Citrine (github.com/greenart7c3/Citrine), an Android app that runs a
full Nostr relay locally on the phone. Because it's "just a relay URL", it folds
into the existing useSkateRelays / nostr.relay() machinery with no new protocol.
What it buys SkateSpots:
- Instant reads: a localhost hit (~1ms) is queried first, remote relays run in
parallel and reconcile — every skate-content surface feels faster.
- Offline-in-the-field: reads work with no signal; writes land on-device and
publish to remote when connectivity returns.
- Resilience/backup: a durable local copy of the user's own events survives
public-relay outages and addressable-event eviction.
- Privacy: local-first data can lean on the device instead of public relays.
Design / safety:
- Android-only (Capacitor native + platform === 'android'); renders nothing and
never probes localhost on iOS/desktop web. Mixed-content ws:// is already
permitted via capacitor allowMixedContent.
- Opt-in only via config.useCitrineRelay (default off). No data is ever routed
through Citrine unless the user enables it AND a relay is actually reachable.
- Reachability probe (short-lived WebSocket) cached through react-query, shared
app-wide, re-checked periodically + on focus, with a manual Recheck button.
- When active, the local URL is prepended to reads and included in writes; the
read-relay set feeds getQueryKeySuffix so caches refetch automatically when
Citrine comes/goes.
- Encryption unchanged: Stash/DMs stay NIP-44 encrypted regardless of
destination — the Settings card states this explicitly. Local ≠ plaintext.
New: src/lib/citrine.ts, src/hooks/useCitrine.ts, CitrineCard in the Relays
settings screen. AppConfig gains useCitrineRelay (zod-validated, optional).
On-device verification still needed: confirm Android permits ws://127.0.0.1
cleartext to localhost (network security config) once Citrine is running.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>