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:
parent
a724dadad1
commit
bd9b71095d
7 changed files with 312 additions and 7 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
66
src/hooks/useCitrine.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
98
src/lib/citrine.ts
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue