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",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.42",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const AppConfigSchema = z.object({
|
||||||
theme: z.enum(['dark', 'light', 'system']),
|
theme: z.enum(['dark', 'light', 'system']),
|
||||||
relayMetadata: RelayMetadataSchema,
|
relayMetadata: RelayMetadataSchema,
|
||||||
useSkateRelay: z.boolean().optional(),
|
useSkateRelay: z.boolean().optional(),
|
||||||
|
useCitrineRelay: z.boolean().optional(),
|
||||||
}) satisfies z.ZodType<Partial<AppConfig>>;
|
}) satisfies z.ZodType<Partial<AppConfig>>;
|
||||||
|
|
||||||
export function AppProvider(props: AppProviderProps) {
|
export function AppProvider(props: AppProviderProps) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ export interface AppConfig {
|
||||||
* Note: Logged-out users always use community relay regardless of this setting.
|
* Note: Logged-out users always use community relay regardless of this setting.
|
||||||
*/
|
*/
|
||||||
useSkateRelay: boolean;
|
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 {
|
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 { useNostr } from '@nostrify/react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAppContext } from '@/hooks/useAppContext';
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
|
import { useCitrine } from '@/hooks/useCitrine';
|
||||||
import { SKATE_RELAY } from '@/lib/types';
|
import { SKATE_RELAY } from '@/lib/types';
|
||||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
|
@ -16,17 +17,29 @@ export function useSkateRelays() {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { config } = useAppContext();
|
const { config } = useAppContext();
|
||||||
|
const citrine = useCitrine();
|
||||||
|
|
||||||
const isLoggedIn = !!user;
|
const isLoggedIn = !!user;
|
||||||
const useSkateRelay = config.useSkateRelay ?? true;
|
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
|
* Get the list of relay URLs to query for skate content
|
||||||
*/
|
*/
|
||||||
const getReadRelays = (): string[] => {
|
const getReadRelays = (): string[] => {
|
||||||
// Logged out: always use community relay only
|
// Logged out: always use community relay only (+ local Citrine if active)
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return [SKATE_RELAY];
|
return withCitrine([SKATE_RELAY]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged in: check setting
|
// Logged in: check setting
|
||||||
|
|
@ -37,11 +50,11 @@ export function useSkateRelays() {
|
||||||
if (useSkateRelay) {
|
if (useSkateRelay) {
|
||||||
// Include community relay + user relays (deduplicated)
|
// Include community relay + user relays (deduplicated)
|
||||||
const relaySet = new Set([SKATE_RELAY, ...userRelays]);
|
const relaySet = new Set([SKATE_RELAY, ...userRelays]);
|
||||||
return Array.from(relaySet);
|
return withCitrine(Array.from(relaySet));
|
||||||
} else {
|
} else {
|
||||||
// User opted out - only their relays
|
// User opted out - only their relays
|
||||||
// If they have no relays configured, fall back to community relay
|
// 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) {
|
if (useSkateRelay) {
|
||||||
// Include community relay + user relays (deduplicated)
|
// Include community relay + user relays (deduplicated)
|
||||||
const relaySet = new Set([SKATE_RELAY, ...userRelays]);
|
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 {
|
} else {
|
||||||
// User opted out - only their relays
|
// User opted out - only their relays
|
||||||
// If they have no relays configured, fall back to community relay
|
// 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,
|
getQueryKeySuffix,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
useSkateRelay,
|
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 { Button } from '@/components/ui/button';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAppContext } from '@/hooks/useAppContext';
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
|
import { useCitrine } from '@/hooks/useCitrine';
|
||||||
import { toast } from '@/hooks/useToast';
|
import { toast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import QRCode from 'qrcode';
|
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 ────────────────────────────────────────────────────────
|
// ─── Relays sub-screen ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RelaysScreen({ onBack }: { onBack: () => void }) {
|
function RelaysScreen({ onBack }: { onBack: () => void }) {
|
||||||
|
|
@ -301,6 +417,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<CitrineCard />
|
||||||
<RelayListManager />
|
<RelayListManager />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue