Citrine: optional local on-device relay for instant reads + offline backup (1.3.0)

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>
This commit is contained in:
shakespeare.diy 2026-09-08 18:53:16 +07:00
parent a724dadad1
commit bd9b71095d
7 changed files with 312 additions and 7 deletions

View file

@ -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": {

View file

@ -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<Partial<AppConfig>>;
export function AppProvider(props: AppProviderProps) {

View file

@ -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 {

66
src/hooks/useCitrine.ts Normal file
View file

@ -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,
};
}

View file

@ -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,
};
}

98
src/lib/citrine.ts Normal file
View file

@ -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<boolean> {
if (!isCitrinePossible()) return Promise.resolve(false);
return new Promise<boolean>((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);
};
});
}

View file

@ -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 (
<div className="rounded-2xl bg-white/3 border border-white/8 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-xl bg-[#ccff00]/10 flex items-center justify-center flex-shrink-0">
<Smartphone className="w-4 h-4 text-[#ccff00]" />
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-white flex items-center gap-2">
Local Relay
<span className="text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70">
Citrine
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{citrine.enabled
? citrine.reachable
? 'Connected — reading & backing up on-device'
: 'Enabled — waiting for Citrine to run'
: 'Faster reads & offline backup on this device'}
</p>
</div>
</div>
<button
onClick={handleToggle}
className={cn(
'relative w-11 h-6 rounded-full transition-all flex-shrink-0',
citrine.enabled ? 'bg-[#ccff00]' : 'bg-white/10',
)}
aria-label="Toggle local Citrine relay"
>
<span
className={cn(
'absolute top-0.5 w-5 h-5 rounded-full bg-black transition-all',
citrine.enabled ? 'left-[calc(100%-22px)]' : 'left-0.5',
)}
/>
</button>
</div>
{citrine.enabled && (
<div className="px-4 pb-4 -mt-1 space-y-3">
<div className="flex items-center gap-2 text-xs">
<span
className={cn(
'inline-block w-2 h-2 rounded-full',
citrine.reachable ? 'bg-[#ccff00]' : 'bg-orange-500',
)}
/>
<span className={citrine.reachable ? 'text-[#ccff00]' : 'text-orange-400'}>
{citrine.reachable ? 'Citrine relay detected' : 'Citrine not detected'}
</span>
<button
onClick={() => citrine.recheck()}
className="ml-auto inline-flex items-center gap-1 text-muted-foreground hover:text-white transition-colors"
>
<RefreshCw className="w-3 h-3" /> Recheck
</button>
</div>
{!citrine.reachable && (
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.06] p-3 space-y-2">
<p className="text-[11px] leading-relaxed text-orange-200/90">
Install the free <strong>Citrine</strong> app and start its relay,
then tap Recheck. It runs a private Nostr relay on your phone for
instant reads and an offline backup.
</p>
<a
href="https://github.com/greenart7c3/Citrine"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-[#ccff00] hover:underline"
>
<Download className="w-3 h-3" /> Get Citrine
<ExternalLink className="w-2.5 h-2.5" />
</a>
</div>
)}
<p className="text-[10px] leading-relaxed text-muted-foreground/60">
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.
</p>
</div>
)}
</div>
);
}
// ─── Relays sub-screen ────────────────────────────────────────────────────────
function RelaysScreen({ onBack }: { onBack: () => void }) {
@ -301,6 +417,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
</button>
</div>
)}
<CitrineCard />
<RelayListManager />
</div>
</div>