Citrine: native cleartext config, default-pool write wiring, polished UI (1.3.1)
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>
This commit is contained in:
parent
bd9b71095d
commit
2e6a20dc2b
7 changed files with 352 additions and 42 deletions
35
android-templates/network_security_config.xml
Normal file
35
android-templates/network_security_config.xml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Android network security config for SkateSpots.
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
───────────────
|
||||||
|
Android API 28+ defaults cleartextTrafficPermitted to FALSE. The optional
|
||||||
|
local Citrine relay (github.com/greenart7c3/Citrine) listens on a plaintext
|
||||||
|
loopback WebSocket (ws://127.0.0.1 / ws://localhost). Without a cleartext
|
||||||
|
allow-entry the OS silently refuses that socket, so the in-app "Local Relay"
|
||||||
|
probe can never turn green even when Citrine is running.
|
||||||
|
|
||||||
|
SCOPE — deliberately minimal:
|
||||||
|
• Cleartext is permitted ONLY for loopback addresses (127.0.0.1, ::1,
|
||||||
|
localhost). Nothing else.
|
||||||
|
• The base config keeps cleartextTrafficPermitted="false", so ALL real
|
||||||
|
network traffic (wss:// relays, https:// APIs, Blossom, etc.) stays
|
||||||
|
encrypted and is unaffected. This is strictly narrower than flipping the
|
||||||
|
global usesCleartextTraffic flag.
|
||||||
|
|
||||||
|
Kept as the source of truth in android-templates/ and copied into
|
||||||
|
android/app/src/main/res/xml/ on every build by
|
||||||
|
scripts/inject-network-security.mjs (idempotent).
|
||||||
|
-->
|
||||||
|
<network-security-config>
|
||||||
|
<!-- Everything not explicitly listed below: no cleartext, encrypted only. -->
|
||||||
|
<base-config cleartextTrafficPermitted="false" />
|
||||||
|
|
||||||
|
<!-- Loopback only: the on-device Citrine relay. -->
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||||
|
<domain includeSubdomains="false">localhost</domain>
|
||||||
|
<domain includeSubdomains="false">::1</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -9,11 +9,11 @@
|
||||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||||
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||||
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
||||||
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs",
|
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs",
|
||||||
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs",
|
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs",
|
||||||
"android:open": "npx cap open android",
|
"android:open": "npx cap open android",
|
||||||
"android:version": "node scripts/sync-android-version.mjs",
|
"android:version": "node scripts/sync-android-version.mjs",
|
||||||
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && cd android && ./gradlew assembleRelease",
|
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && cd android && ./gradlew assembleRelease",
|
||||||
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html"
|
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
85
scripts/inject-network-security.mjs
Normal file
85
scripts/inject-network-security.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Inject the network security config that allows the optional local Citrine
|
||||||
|
* relay's loopback cleartext WebSocket (ws://127.0.0.1).
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* ───────────────
|
||||||
|
* Android API 28+ blocks cleartext (non-TLS) traffic by default. Citrine
|
||||||
|
* (github.com/greenart7c3/Citrine) runs an on-device Nostr relay on a plaintext
|
||||||
|
* localhost WebSocket, so without a loopback-scoped cleartext allow-entry the OS
|
||||||
|
* silently refuses the connection and the in-app "Local Relay" toggle can never
|
||||||
|
* detect it.
|
||||||
|
*
|
||||||
|
* This script (idempotent, part of the android build):
|
||||||
|
* 1. Copies android-templates/network_security_config.xml into
|
||||||
|
* android/app/src/main/res/xml/.
|
||||||
|
* 2. Adds android:networkSecurityConfig="@xml/network_security_config" to the
|
||||||
|
* <application> tag in AndroidManifest.xml if not already present.
|
||||||
|
*
|
||||||
|
* The config permits cleartext for LOOPBACK ONLY — all real traffic (wss://,
|
||||||
|
* https://) stays encrypted. Exits quietly if android/ isn't generated yet.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const appDir = join(root, 'android', 'app');
|
||||||
|
|
||||||
|
if (!existsSync(appDir)) {
|
||||||
|
console.log('[inject-network-security] android/ not generated yet — skipping.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Copy the XML resource into res/xml/ ──────────────────────────────────
|
||||||
|
const template = join(root, 'android-templates', 'network_security_config.xml');
|
||||||
|
if (!existsSync(template)) {
|
||||||
|
console.error('[inject-network-security] template android-templates/network_security_config.xml missing — aborting.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlDir = join(appDir, 'src', 'main', 'res', 'xml');
|
||||||
|
if (!existsSync(xmlDir)) {
|
||||||
|
mkdirSync(xmlDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = join(xmlDir, 'network_security_config.xml');
|
||||||
|
const desired = readFileSync(template, 'utf8');
|
||||||
|
const current = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
||||||
|
if (current !== desired) {
|
||||||
|
copyFileSync(template, target);
|
||||||
|
console.log('[inject-network-security] ✓ Wrote res/xml/network_security_config.xml.');
|
||||||
|
} else {
|
||||||
|
console.log('[inject-network-security] res/xml/network_security_config.xml already correct.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Reference it from the <application> tag in the manifest ──────────────
|
||||||
|
const manifestPath = join(appDir, 'src', 'main', 'AndroidManifest.xml');
|
||||||
|
if (!existsSync(manifestPath)) {
|
||||||
|
console.log('[inject-network-security] AndroidManifest.xml not found — skipping manifest wiring.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest = readFileSync(manifestPath, 'utf8');
|
||||||
|
|
||||||
|
if (manifest.includes('android:networkSecurityConfig')) {
|
||||||
|
console.log('[inject-network-security] manifest already references a network security config.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the attribute to the opening <application ...> tag. Capacitor always
|
||||||
|
// emits an <application> element; we insert the attribute right after the tag
|
||||||
|
// name so it merges cleanly regardless of the other attributes' order.
|
||||||
|
const appTagRegex = /<application\b/;
|
||||||
|
if (!appTagRegex.test(manifest)) {
|
||||||
|
console.error('[inject-network-security] No <application> tag found — aborting.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest = manifest.replace(
|
||||||
|
appTagRegex,
|
||||||
|
'<application android:networkSecurityConfig="@xml/network_security_config"',
|
||||||
|
);
|
||||||
|
writeFileSync(manifestPath, manifest);
|
||||||
|
console.log('[inject-network-security] ✓ Added android:networkSecurityConfig to <application>.');
|
||||||
|
|
@ -3,6 +3,7 @@ import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify';
|
||||||
import { NostrContext } from '@nostrify/react';
|
import { NostrContext } from '@nostrify/react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
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';
|
||||||
|
|
||||||
interface NostrProviderProps {
|
interface NostrProviderProps {
|
||||||
|
|
@ -21,6 +22,7 @@ const DEFAULT_RELAYS = [
|
||||||
const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
const { config } = useAppContext();
|
const { config } = useAppContext();
|
||||||
|
const citrine = useCitrine();
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -30,6 +32,10 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||||
// Use refs so the pool always has the latest data
|
// Use refs so the pool always has the latest data
|
||||||
const relayMetadata = useRef(config.relayMetadata);
|
const relayMetadata = useRef(config.relayMetadata);
|
||||||
const useSkateRelay = useRef(config.useSkateRelay ?? true);
|
const useSkateRelay = useRef(config.useSkateRelay ?? true);
|
||||||
|
// Local Citrine relay URL when active (Android + opted-in + reachable), else
|
||||||
|
// null. Kept in a ref so the once-initialized pool's routers always read the
|
||||||
|
// current value without recreating the pool.
|
||||||
|
const citrineUrl = useRef<string | null>(citrine.url);
|
||||||
|
|
||||||
// Invalidate Nostr queries when relay metadata changes
|
// Invalidate Nostr queries when relay metadata changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -38,6 +44,13 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['nostr'] });
|
queryClient.invalidateQueries({ queryKey: ['nostr'] });
|
||||||
}, [config.relayMetadata, config.useSkateRelay, queryClient]);
|
}, [config.relayMetadata, config.useSkateRelay, queryClient]);
|
||||||
|
|
||||||
|
// Keep the Citrine ref current and refresh queries when the local relay comes
|
||||||
|
// or goes, so reads immediately start (or stop) hitting it.
|
||||||
|
useEffect(() => {
|
||||||
|
citrineUrl.current = citrine.url;
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nostr'] });
|
||||||
|
}, [citrine.url, queryClient]);
|
||||||
|
|
||||||
// Initialize NPool only once
|
// Initialize NPool only once
|
||||||
if (!pool.current) {
|
if (!pool.current) {
|
||||||
pool.current = new NPool({
|
pool.current = new NPool({
|
||||||
|
|
@ -66,6 +79,14 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||||
routes.set(SKATE_RELAY, filters);
|
routes.set(SKATE_RELAY, filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Local Citrine relay (Android, opt-in, reachable) ---
|
||||||
|
// Query the on-device relay alongside remotes. NPool runs routes in
|
||||||
|
// parallel and the localhost hit returns first (~1ms), so reads feel
|
||||||
|
// instant while remotes reconcile in the background.
|
||||||
|
if (citrineUrl.current) {
|
||||||
|
routes.set(citrineUrl.current, filters);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure we always have at least one relay
|
// Ensure we always have at least one relay
|
||||||
if (readRelays.length === 0 && routes.size === 0) {
|
if (readRelays.length === 0 && routes.size === 0) {
|
||||||
console.warn('[NostrProvider] No read relays configured, using defaults');
|
console.warn('[NostrProvider] No read relays configured, using defaults');
|
||||||
|
|
@ -106,6 +127,13 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||||
allRelays.add(url);
|
allRelays.add(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Local Citrine relay (Android, opt-in, reachable): write here too so
|
||||||
|
// the device keeps a durable copy (backup) and captures the event even
|
||||||
|
// when offline, ready to sync to remotes when connectivity returns.
|
||||||
|
if (citrineUrl.current) {
|
||||||
|
allRelays.add(citrineUrl.current);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure we always have at least one relay
|
// Ensure we always have at least one relay
|
||||||
if (allRelays.size === 0) {
|
if (allRelays.size === 0) {
|
||||||
console.warn('No write relays configured, using defaults');
|
console.warn('No write relays configured, using defaults');
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function useCitrine() {
|
||||||
const possible = isCitrinePossible();
|
const possible = isCitrinePossible();
|
||||||
const enabled = config.useCitrineRelay ?? false;
|
const enabled = config.useCitrineRelay ?? false;
|
||||||
|
|
||||||
const { data: reachable = false, refetch } = useQuery({
|
const { data: reachable = false, refetch, isFetching, isFetched } = useQuery({
|
||||||
queryKey: ['citrine-reachable', CITRINE_RELAY_URL],
|
queryKey: ['citrine-reachable', CITRINE_RELAY_URL],
|
||||||
// Only bother probing when the platform could host Citrine AND the user has
|
// Only bother probing when the platform could host Citrine AND the user has
|
||||||
// opted in — no point connecting to localhost otherwise.
|
// opted in — no point connecting to localhost otherwise.
|
||||||
|
|
@ -58,6 +58,14 @@ export function useCitrine() {
|
||||||
enabled,
|
enabled,
|
||||||
reachable,
|
reachable,
|
||||||
active,
|
active,
|
||||||
|
/** True while a reachability probe is in flight (for spinner UI). */
|
||||||
|
probing: isFetching,
|
||||||
|
/**
|
||||||
|
* True once a probe has completed at least once. Used to avoid flashing the
|
||||||
|
* "not detected" state on the very first render after enabling, before the
|
||||||
|
* initial probe has had a chance to run.
|
||||||
|
*/
|
||||||
|
settled: isFetched,
|
||||||
url: active ? CITRINE_RELAY_URL : null,
|
url: active ? CITRINE_RELAY_URL : null,
|
||||||
setEnabled,
|
setEnabled,
|
||||||
/** Re-run the reachability probe (e.g. after the user starts Citrine). */
|
/** Re-run the reachability probe (e.g. after the user starts Citrine). */
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ import {
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Download,
|
Download,
|
||||||
Globe,
|
Globe,
|
||||||
|
Zap,
|
||||||
|
WifiOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -268,6 +270,12 @@ function Nip05Screen({ onBack }: { onBack: () => void }) {
|
||||||
* non-Android platforms (citrine.possible === false). When enabled, the local
|
* non-Android platforms (citrine.possible === false). When enabled, the local
|
||||||
* relay is folded into skate-content reads (instant local hits) and writes
|
* relay is folded into skate-content reads (instant local hits) and writes
|
||||||
* (on-device backup + offline capture) via useSkateRelays.
|
* (on-device backup + offline capture) via useSkateRelays.
|
||||||
|
*
|
||||||
|
* UX states:
|
||||||
|
* off — muted, invites the user to speed things up.
|
||||||
|
* searching — enabled, probe in flight: breathing lime dot.
|
||||||
|
* live — enabled + reachable: glowing lime, benefit chips, celebratory.
|
||||||
|
* waiting — enabled + not reachable: orange, actionable install CTA.
|
||||||
*/
|
*/
|
||||||
function CitrineCard() {
|
function CitrineCard() {
|
||||||
const citrine = useCitrine();
|
const citrine = useCitrine();
|
||||||
|
|
@ -275,101 +283,213 @@ function CitrineCard() {
|
||||||
// Not on native Android → the local relay can't exist. Hide entirely.
|
// Not on native Android → the local relay can't exist. Hide entirely.
|
||||||
if (!citrine.possible) return null;
|
if (!citrine.possible) return null;
|
||||||
|
|
||||||
|
const state: 'off' | 'searching' | 'live' | 'waiting' = !citrine.enabled
|
||||||
|
? 'off'
|
||||||
|
: citrine.reachable
|
||||||
|
? 'live'
|
||||||
|
: citrine.probing || !citrine.settled
|
||||||
|
? 'searching'
|
||||||
|
: 'waiting';
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
const next = !citrine.enabled;
|
const next = !citrine.enabled;
|
||||||
citrine.setEnabled(next);
|
citrine.setEnabled(next);
|
||||||
toast({
|
toast({
|
||||||
title: next ? 'Local relay enabled' : 'Local relay disabled',
|
title: next ? 'Local relay on' : 'Local relay off',
|
||||||
description: next
|
description: next
|
||||||
? 'SkateSpots will read from and back up to Citrine on this device.'
|
? 'Looking for Citrine on this device…'
|
||||||
: 'No longer using the on-device relay.',
|
: 'No longer using the on-device relay.',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl bg-white/3 border border-white/8 overflow-hidden">
|
<div
|
||||||
<div className="flex items-center justify-between p-4">
|
className={cn(
|
||||||
|
'rounded-2xl border overflow-hidden transition-all duration-500',
|
||||||
|
state === 'live'
|
||||||
|
? 'border-[#ccff00]/40 bg-[#ccff00]/[0.05] shadow-[0_0_28px_-8px_rgba(204,255,0,0.35)]'
|
||||||
|
: state === 'waiting'
|
||||||
|
? 'border-orange-500/25 bg-orange-500/[0.04]'
|
||||||
|
: 'border-white/8 bg-white/3',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between gap-3 p-4">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<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">
|
{/* Icon tile with live status pip */}
|
||||||
<Smartphone className="w-4 h-4 text-[#ccff00]" />
|
<div className="relative flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-10 h-10 rounded-xl flex items-center justify-center transition-colors duration-500',
|
||||||
|
state === 'live'
|
||||||
|
? 'bg-[#ccff00]/15'
|
||||||
|
: state === 'waiting'
|
||||||
|
? 'bg-orange-500/10'
|
||||||
|
: 'bg-white/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Smartphone
|
||||||
|
className={cn(
|
||||||
|
'w-[18px] h-[18px] transition-colors duration-500',
|
||||||
|
state === 'live'
|
||||||
|
? 'text-[#ccff00]'
|
||||||
|
: state === 'waiting'
|
||||||
|
? 'text-orange-400'
|
||||||
|
: 'text-white/70',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{citrine.enabled && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-black',
|
||||||
|
state === 'live' && 'bg-[#ccff00] animate-status-live',
|
||||||
|
state === 'searching' && 'bg-[#ccff00] animate-status-breathe',
|
||||||
|
state === 'waiting' && 'bg-orange-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-bold text-white flex items-center gap-2">
|
<p className="text-sm font-bold text-white flex items-center gap-2 leading-none">
|
||||||
Local Relay
|
Local Relay
|
||||||
<span className="text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70">
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[9px] font-black uppercase tracking-[0.15em] transition-colors',
|
||||||
|
state === 'live' ? 'text-[#ccff00]' : 'text-white/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
Citrine
|
Citrine
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p
|
||||||
{citrine.enabled
|
className={cn(
|
||||||
? citrine.reachable
|
'text-xs mt-1 transition-colors duration-300',
|
||||||
? 'Connected — reading & backing up on-device'
|
state === 'live'
|
||||||
: 'Enabled — waiting for Citrine to run'
|
? 'text-[#ccff00]/90'
|
||||||
: 'Faster reads & offline backup on this device'}
|
: state === 'waiting'
|
||||||
|
? 'text-orange-300/80'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state === 'off' && 'Faster reads & offline backup on this device'}
|
||||||
|
{state === 'searching' && 'Searching for Citrine on this device…'}
|
||||||
|
{state === 'live' && 'Connected — reading & backing up locally'}
|
||||||
|
{state === 'waiting' && 'Enabled — start Citrine to connect'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative w-11 h-6 rounded-full transition-all flex-shrink-0',
|
'relative w-11 h-6 rounded-full transition-all duration-300 flex-shrink-0 active:scale-95',
|
||||||
citrine.enabled ? 'bg-[#ccff00]' : 'bg-white/10',
|
citrine.enabled ? 'bg-[#ccff00]' : 'bg-white/10',
|
||||||
)}
|
)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={citrine.enabled}
|
||||||
aria-label="Toggle local Citrine relay"
|
aria-label="Toggle local Citrine relay"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-0.5 w-5 h-5 rounded-full bg-black transition-all',
|
'absolute top-0.5 w-5 h-5 rounded-full bg-black transition-all duration-300 ease-out',
|
||||||
citrine.enabled ? 'left-[calc(100%-22px)]' : 'left-0.5',
|
citrine.enabled ? 'left-[calc(100%-22px)]' : 'left-0.5',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expanding detail — only when enabled */}
|
||||||
{citrine.enabled && (
|
{citrine.enabled && (
|
||||||
<div className="px-4 pb-4 -mt-1 space-y-3">
|
<div className="px-4 pb-4 space-y-3 animate-in fade-in slide-in-from-top-1 duration-300">
|
||||||
|
{/* Status row + recheck */}
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-block w-2 h-2 rounded-full',
|
'inline-flex w-2 h-2 rounded-full',
|
||||||
citrine.reachable ? 'bg-[#ccff00]' : 'bg-orange-500',
|
state === 'live' && 'bg-[#ccff00] animate-status-live',
|
||||||
|
state === 'searching' && 'bg-[#ccff00] animate-status-breathe',
|
||||||
|
state === 'waiting' && 'bg-orange-500',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className={citrine.reachable ? 'text-[#ccff00]' : 'text-orange-400'}>
|
<span
|
||||||
{citrine.reachable ? 'Citrine relay detected' : 'Citrine not detected'}
|
className={cn(
|
||||||
|
'font-medium',
|
||||||
|
state === 'live'
|
||||||
|
? 'text-[#ccff00]'
|
||||||
|
: state === 'searching'
|
||||||
|
? 'text-[#ccff00]/70'
|
||||||
|
: 'text-orange-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state === 'live' && 'Citrine relay detected'}
|
||||||
|
{state === 'searching' && 'Looking for Citrine…'}
|
||||||
|
{state === 'waiting' && 'Citrine not detected'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => citrine.recheck()}
|
onClick={() => citrine.recheck()}
|
||||||
className="ml-auto inline-flex items-center gap-1 text-muted-foreground hover:text-white transition-colors"
|
disabled={citrine.probing}
|
||||||
|
className={cn(
|
||||||
|
'ml-auto inline-flex items-center gap-1 rounded-full px-2 py-1 -mr-1 text-muted-foreground',
|
||||||
|
'hover:text-white hover:bg-white/5 transition-colors disabled:opacity-50',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3 h-3" /> Recheck
|
<RefreshCw className={cn('w-3 h-3', citrine.probing && 'animate-spin')} />
|
||||||
|
{citrine.probing ? 'Checking' : 'Recheck'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!citrine.reachable && (
|
{/* LIVE: benefit chips — the reward for connecting */}
|
||||||
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.06] p-3 space-y-2">
|
{state === 'live' && (
|
||||||
<p className="text-[11px] leading-relaxed text-orange-200/90">
|
<div className="flex flex-wrap gap-1.5 animate-pop-in">
|
||||||
Install the free <strong>Citrine</strong> app and start its relay,
|
{[
|
||||||
then tap Recheck. It runs a private Nostr relay on your phone for
|
{ icon: Zap, label: 'Instant reads' },
|
||||||
instant reads and an offline backup.
|
{ icon: WifiOff, label: 'Works offline' },
|
||||||
|
{ icon: Database, label: 'On-device backup' },
|
||||||
|
].map(({ icon: Icon, label }) => (
|
||||||
|
<span
|
||||||
|
key={label}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-[#ccff00]/12 border border-[#ccff00]/25 px-2.5 py-1 text-[10px] font-bold text-[#ccff00]"
|
||||||
|
>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* WAITING: install / start CTA */}
|
||||||
|
{state === 'waiting' && (
|
||||||
|
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.06] p-3 space-y-2.5">
|
||||||
|
<p className="text-[11px] leading-relaxed text-orange-100/90">
|
||||||
|
<strong className="text-orange-300">Citrine isn't running yet.</strong>{' '}
|
||||||
|
Install the free app, open it and start the relay, then tap Recheck.
|
||||||
|
It runs a private Nostr relay on your phone for instant reads and an
|
||||||
|
offline backup.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/greenart7c3/Citrine"
|
href="https://github.com/greenart7c3/Citrine"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 text-[11px] font-bold text-[#ccff00] hover:underline"
|
className="inline-flex items-center gap-1.5 rounded-full bg-[#ccff00] px-3 py-1.5 text-[11px] font-black uppercase tracking-wide text-black hover:bg-[#ccff00]/90 active:scale-95 transition-all"
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" /> Get Citrine
|
<Download className="w-3.5 h-3.5" /> Get Citrine
|
||||||
<ExternalLink className="w-2.5 h-2.5" />
|
<ExternalLink className="w-2.5 h-2.5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-[10px] leading-relaxed text-muted-foreground/60">
|
{/* Privacy reassurance — always present when enabled */}
|
||||||
Private data (Stash, messages) stays end-to-end encrypted here just
|
<div className="flex items-start gap-2 rounded-xl bg-black/20 border border-white/5 p-2.5">
|
||||||
like anywhere else. The local relay only speeds things up and keeps a
|
<Shield className="w-3.5 h-3.5 text-white/40 flex-shrink-0 mt-0.5" />
|
||||||
copy on your device — it never replaces encryption.
|
<p className="text-[10px] leading-relaxed text-muted-foreground/70">
|
||||||
</p>
|
Your Stash and messages stay end-to-end encrypted here, exactly like
|
||||||
|
everywhere else. The local relay only speeds things up and keeps a
|
||||||
|
copy on your device — it never replaces encryption.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -107,11 +107,45 @@ export default {
|
||||||
'94%, 98%': {
|
'94%, 98%': {
|
||||||
transform: 'rotate(12deg)'
|
transform: 'rotate(12deg)'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'status-breathe': {
|
||||||
|
'0%, 100%': {
|
||||||
|
opacity: '1',
|
||||||
|
boxShadow: '0 0 0 0 rgba(204,255,0,0)'
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
opacity: '0.55',
|
||||||
|
boxShadow: '0 0 0 4px rgba(204,255,0,0.12)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'status-live': {
|
||||||
|
'0%, 100%': {
|
||||||
|
boxShadow: '0 0 6px 0 rgba(204,255,0,0.9)'
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
boxShadow: '0 0 10px 2px rgba(204,255,0,0.55)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'pop-in': {
|
||||||
|
'0%': {
|
||||||
|
transform: 'scale(0.6)',
|
||||||
|
opacity: '0'
|
||||||
|
},
|
||||||
|
'70%': {
|
||||||
|
transform: 'scale(1.08)'
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'scale(1)',
|
||||||
|
opacity: '1'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out'
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
'status-breathe': 'status-breathe 1.8s ease-in-out infinite',
|
||||||
|
'status-live': 'status-live 2.4s ease-in-out infinite',
|
||||||
|
'pop-in': 'pop-in 0.4s cubic-bezier(0.34,1.56,0.64,1) both'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue