From bd9b71095d371319dbb6fcdf90c4aa869b641c78 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 8 Sep 2026 18:53:16 +0700 Subject: [PATCH] Citrine: optional local on-device relay for instant reads + offline backup (1.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- src/components/AppProvider.tsx | 1 + src/contexts/AppContext.ts | 6 ++ src/hooks/useCitrine.ts | 66 +++++++++++++++++++ src/hooks/useSkateRelays.ts | 29 ++++++-- src/lib/citrine.ts | 98 +++++++++++++++++++++++++++ src/pages/SettingsPage.tsx | 117 +++++++++++++++++++++++++++++++++ 7 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 src/hooks/useCitrine.ts create mode 100644 src/lib/citrine.ts diff --git a/package.json b/package.json index dcba20c..5cc669e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.42", + "version": "1.3.0", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 12a770e..64e536a 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -26,6 +26,7 @@ const AppConfigSchema = z.object({ theme: z.enum(['dark', 'light', 'system']), relayMetadata: RelayMetadataSchema, useSkateRelay: z.boolean().optional(), + useCitrineRelay: z.boolean().optional(), }) satisfies z.ZodType>; export function AppProvider(props: AppProviderProps) { diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 23bf70e..3ca6318 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -20,6 +20,12 @@ export interface AppConfig { * Note: Logged-out users always use community relay regardless of this setting. */ useSkateRelay: boolean; + /** Whether to use a local on-device Citrine relay (Android only) for skate + * content. When true AND a Citrine relay is reachable, it is prepended to + * reads (instant local hits) and included in writes (local backup + offline + * capture). Opt-in and no-op on non-Android / when Citrine isn't running. + * Defaults to false. */ + useCitrineRelay?: boolean; } export interface AppContextType { diff --git a/src/hooks/useCitrine.ts b/src/hooks/useCitrine.ts new file mode 100644 index 0000000..e888f60 --- /dev/null +++ b/src/hooks/useCitrine.ts @@ -0,0 +1,66 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useAppContext } from '@/hooks/useAppContext'; +import { + CITRINE_RELAY_URL, + isCitrinePossible, + probeCitrine, +} from '@/lib/citrine'; + +/** + * useCitrine — availability + opt-in state for the local on-device Citrine relay. + * + * Exposes everything the app needs to decide whether the local relay should be + * folded into reads/writes: + * - `possible`: cheap sync gate — are we on native Android at all? + * - `reachable`: async probe result — is a Citrine relay actually running? + * - `enabled`: the user's opt-in setting (config.useCitrineRelay). + * - `active`: enabled AND reachable — the single boolean callers should key on. + * - `url`: the effective relay URL to use when `active`, else null. + * - `setEnabled`: toggle the opt-in setting. + * + * The reachability probe is cached via react-query so the whole app shares one + * result and we don't spam localhost connection attempts. On non-Android the + * probe short-circuits to false, so this hook is a safe no-op everywhere else. + */ +export function useCitrine() { + const { config, updateConfig } = useAppContext(); + + const possible = isCitrinePossible(); + const enabled = config.useCitrineRelay ?? false; + + const { data: reachable = false, refetch } = useQuery({ + queryKey: ['citrine-reachable', CITRINE_RELAY_URL], + // Only bother probing when the platform could host Citrine AND the user has + // opted in — no point connecting to localhost otherwise. + enabled: possible && enabled, + queryFn: () => probeCitrine(CITRINE_RELAY_URL), + // Reachability changes rarely within a session (app installed/running or + // not); keep it warm but re-verify periodically so a freshly-launched + // Citrine is picked up without a manual refresh. + staleTime: 60_000, + refetchInterval: 5 * 60_000, + refetchOnWindowFocus: true, + retry: false, + }); + + const active = possible && enabled && reachable; + + const setEnabled = useCallback( + (next: boolean) => { + updateConfig((current) => ({ ...current, useCitrineRelay: next })); + }, + [updateConfig], + ); + + return { + possible, + enabled, + reachable, + active, + url: active ? CITRINE_RELAY_URL : null, + setEnabled, + /** Re-run the reachability probe (e.g. after the user starts Citrine). */ + recheck: refetch, + }; +} diff --git a/src/hooks/useSkateRelays.ts b/src/hooks/useSkateRelays.ts index eca7035..a57f4d4 100644 --- a/src/hooks/useSkateRelays.ts +++ b/src/hooks/useSkateRelays.ts @@ -1,6 +1,7 @@ import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; +import { useCitrine } from '@/hooks/useCitrine'; import { SKATE_RELAY } from '@/lib/types'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; @@ -16,17 +17,29 @@ export function useSkateRelays() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { config } = useAppContext(); + const citrine = useCitrine(); const isLoggedIn = !!user; const useSkateRelay = config.useSkateRelay ?? true; + /** + * Prepend the local Citrine relay (when active) to a relay list, deduped. + * Local-first: a localhost hit returns in ~1ms so it should be queried first; + * remote relays still run in parallel and reconcile. No-op unless the user + * opted in AND a Citrine relay is actually reachable (Android only). + */ + const withCitrine = (relays: string[]): string[] => { + if (!citrine.url) return relays; + return Array.from(new Set([citrine.url, ...relays])); + }; + /** * Get the list of relay URLs to query for skate content */ const getReadRelays = (): string[] => { - // Logged out: always use community relay only + // Logged out: always use community relay only (+ local Citrine if active) if (!isLoggedIn) { - return [SKATE_RELAY]; + return withCitrine([SKATE_RELAY]); } // Logged in: check setting @@ -37,11 +50,11 @@ export function useSkateRelays() { if (useSkateRelay) { // Include community relay + user relays (deduplicated) const relaySet = new Set([SKATE_RELAY, ...userRelays]); - return Array.from(relaySet); + return withCitrine(Array.from(relaySet)); } else { // User opted out - only their relays // If they have no relays configured, fall back to community relay - return userRelays.length > 0 ? userRelays : [SKATE_RELAY]; + return withCitrine(userRelays.length > 0 ? userRelays : [SKATE_RELAY]); } }; @@ -61,11 +74,13 @@ export function useSkateRelays() { if (useSkateRelay) { // Include community relay + user relays (deduplicated) const relaySet = new Set([SKATE_RELAY, ...userRelays]); - return Array.from(relaySet); + // Also write to the local Citrine relay when active — a durable on-device + // copy (backup + offline capture) alongside the remote publish. + return withCitrine(Array.from(relaySet)); } else { // User opted out - only their relays // If they have no relays configured, fall back to community relay - return userRelays.length > 0 ? userRelays : [SKATE_RELAY]; + return withCitrine(userRelays.length > 0 ? userRelays : [SKATE_RELAY]); } }; @@ -169,5 +184,7 @@ export function useSkateRelays() { getQueryKeySuffix, isLoggedIn, useSkateRelay, + /** Whether the local Citrine relay is currently folded into reads/writes. */ + citrineActive: citrine.active, }; } diff --git a/src/lib/citrine.ts b/src/lib/citrine.ts new file mode 100644 index 0000000..2b5f9c1 --- /dev/null +++ b/src/lib/citrine.ts @@ -0,0 +1,98 @@ +/** + * citrine — integration helpers for a local on-device Nostr relay. + * ───────────────────────────────────────────────────────────────────────────── + * Citrine (github.com/greenart7c3/Citrine) is an Android app by the author of + * Amber that runs a full Nostr relay locally on the phone, storing events in an + * on-device SQLite database and listening on a localhost WebSocket. Because it + * is "just a relay URL", it slots into our existing nostr.relay()/group() and + * useSkateRelays machinery with no new protocol. + * + * Why we want it (SkateSpots-specific): + * - Offline-in-the-field: read spots/tours/events at a spot with no signal, and + * capture writes locally to publish when connectivity returns. + * - Instant reads: a localhost query returns in ~1ms vs. 100–800ms remote. + * - Resilience/backup: a durable local copy of the user's own events survives + * public-relay outages and addressable-event eviction. + * - Privacy: local-first event classes (e.g. Stash) can avoid public relays. + * + * Hard constraints (must be respected everywhere Citrine is used): + * - Android-only. iOS/desktop web get none of this — always platform-gate. + * - Opt-in only. Never route data through Citrine without an explicit setting. + * - Local ≠ encrypted. Citrine stores whatever we give it verbatim; sensitive + * data (Stash, DMs) must stay NIP-44 encrypted regardless of destination. + */ + +/** Citrine's default localhost relay endpoint. */ +export const CITRINE_RELAY_URL = 'ws://127.0.0.1:4869'; + +/** + * True when running inside a Capacitor native Android shell. Citrine is an + * Android app, so the local relay can only exist there. This never throws in a + * plain browser (window.Capacitor is simply undefined). + */ +export function isAndroidNative(): boolean { + const cap = (globalThis as { + Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; + }).Capacitor; + if (!cap?.isNativePlatform?.()) return false; + return cap.getPlatform?.() === 'android'; +} + +/** + * True if the Citrine relay URL *could* be reachable on this device — i.e. we're + * on native Android. This is a cheap synchronous gate used to decide whether to + * even offer the feature / attempt a probe. Actual reachability is confirmed by + * probeCitrine(). + */ +export function isCitrinePossible(): boolean { + return isAndroidNative(); +} + +/** + * Probe whether a Citrine relay is actually running and reachable at `url` by + * opening a short-lived WebSocket. Resolves true only on a clean open; resolves + * false on error or timeout. Never rejects, never throws — safe to call from a + * query or effect. + * + * We only probe on Android (isCitrinePossible) to avoid pointless localhost + * connection attempts (and console noise) in the browser/desktop. + */ +export function probeCitrine( + url: string = CITRINE_RELAY_URL, + timeoutMs = 2500, +): Promise { + if (!isCitrinePossible()) return Promise.resolve(false); + + return new Promise((resolve) => { + let settled = false; + const done = (result: boolean, ws?: WebSocket) => { + if (settled) return; + settled = true; + try { + ws?.close(); + } catch { + // ignore close errors + } + resolve(result); + }; + + let ws: WebSocket; + try { + ws = new WebSocket(url); + } catch { + resolve(false); + return; + } + + const timer = setTimeout(() => done(false, ws), timeoutMs); + + ws.onopen = () => { + clearTimeout(timer); + done(true, ws); + }; + ws.onerror = () => { + clearTimeout(timer); + done(false, ws); + }; + }); +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index e989cd3..fdd0a69 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -22,6 +22,7 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { Button } from '@/components/ui/button'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; +import { useCitrine } from '@/hooks/useCitrine'; import { toast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import QRCode from 'qrcode'; @@ -260,6 +261,121 @@ function Nip05Screen({ onBack }: { onBack: () => void }) { ); } +// ─── Local relay (Citrine) card ─────────────────────────────────────────────── + +/** + * Android-only opt-in for the local on-device Citrine relay. Renders nothing on + * non-Android platforms (citrine.possible === false). When enabled, the local + * relay is folded into skate-content reads (instant local hits) and writes + * (on-device backup + offline capture) via useSkateRelays. + */ +function CitrineCard() { + const citrine = useCitrine(); + + // Not on native Android → the local relay can't exist. Hide entirely. + if (!citrine.possible) return null; + + const handleToggle = () => { + const next = !citrine.enabled; + citrine.setEnabled(next); + toast({ + title: next ? 'Local relay enabled' : 'Local relay disabled', + description: next + ? 'SkateSpots will read from and back up to Citrine on this device.' + : 'No longer using the on-device relay.', + }); + }; + + return ( +
+
+
+
+ +
+
+

+ Local Relay + + Citrine + +

+

+ {citrine.enabled + ? citrine.reachable + ? 'Connected — reading & backing up on-device' + : 'Enabled — waiting for Citrine to run' + : 'Faster reads & offline backup on this device'} +

+
+
+ +
+ + {citrine.enabled && ( +
+
+ + + {citrine.reachable ? 'Citrine relay detected' : 'Citrine not detected'} + + +
+ + {!citrine.reachable && ( +
+

+ Install the free Citrine app and start its relay, + then tap Recheck. It runs a private Nostr relay on your phone for + instant reads and an offline backup. +

+ + Get Citrine + + +
+ )} + +

+ Private data (Stash, messages) stays end-to-end encrypted here just + like anywhere else. The local relay only speeds things up and keeps a + copy on your device — it never replaces encryption. +

+
+ )} +
+ ); +} + // ─── Relays sub-screen ──────────────────────────────────────────────────────── function RelaysScreen({ onBack }: { onBack: () => void }) { @@ -301,6 +417,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) { )} +