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",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"type": "module",
|
||||
"homepage": "https://skate-spots.com",
|
||||
"scripts": {
|
||||
|
|
@ -9,11 +9,11 @@
|
|||
"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!'",
|
||||
"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: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: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 && node scripts/inject-network-security.mjs",
|
||||
"android:open": "npx cap open android",
|
||||
"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"
|
||||
},
|
||||
"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 { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCitrine } from '@/hooks/useCitrine';
|
||||
import { SKATE_RELAY } from '@/lib/types';
|
||||
|
||||
interface NostrProviderProps {
|
||||
|
|
@ -21,6 +22,7 @@ const DEFAULT_RELAYS = [
|
|||
const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||
const { children } = props;
|
||||
const { config } = useAppContext();
|
||||
const citrine = useCitrine();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -30,6 +32,10 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
|||
// Use refs so the pool always has the latest data
|
||||
const relayMetadata = useRef(config.relayMetadata);
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -38,6 +44,13 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
|||
queryClient.invalidateQueries({ queryKey: ['nostr'] });
|
||||
}, [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
|
||||
if (!pool.current) {
|
||||
pool.current = new NPool({
|
||||
|
|
@ -66,6 +79,14 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
|||
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
|
||||
if (readRelays.length === 0 && routes.size === 0) {
|
||||
console.warn('[NostrProvider] No read relays configured, using defaults');
|
||||
|
|
@ -106,6 +127,13 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
|||
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
|
||||
if (allRelays.size === 0) {
|
||||
console.warn('No write relays configured, using defaults');
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function useCitrine() {
|
|||
const possible = isCitrinePossible();
|
||||
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],
|
||||
// Only bother probing when the platform could host Citrine AND the user has
|
||||
// opted in — no point connecting to localhost otherwise.
|
||||
|
|
@ -58,6 +58,14 @@ export function useCitrine() {
|
|||
enabled,
|
||||
reachable,
|
||||
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,
|
||||
setEnabled,
|
||||
/** Re-run the reachability probe (e.g. after the user starts Citrine). */
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ import {
|
|||
Smartphone,
|
||||
Download,
|
||||
Globe,
|
||||
Zap,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -268,6 +270,12 @@ function Nip05Screen({ onBack }: { onBack: () => void }) {
|
|||
* 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.
|
||||
*
|
||||
* 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() {
|
||||
const citrine = useCitrine();
|
||||
|
|
@ -275,101 +283,213 @@ function CitrineCard() {
|
|||
// Not on native Android → the local relay can't exist. Hide entirely.
|
||||
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 next = !citrine.enabled;
|
||||
citrine.setEnabled(next);
|
||||
toast({
|
||||
title: next ? 'Local relay enabled' : 'Local relay disabled',
|
||||
title: next ? 'Local relay on' : 'Local relay off',
|
||||
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.',
|
||||
});
|
||||
};
|
||||
|
||||
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={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="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]" />
|
||||
{/* Icon tile with live status pip */}
|
||||
<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 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
|
||||
<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
|
||||
</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
|
||||
className={cn(
|
||||
'text-xs mt-1 transition-colors duration-300',
|
||||
state === 'live'
|
||||
? 'text-[#ccff00]/90'
|
||||
: 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
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',
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={citrine.enabled}
|
||||
aria-label="Toggle local Citrine relay"
|
||||
>
|
||||
<span
|
||||
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',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanding detail — only when 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">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-2 h-2 rounded-full',
|
||||
citrine.reachable ? 'bg-[#ccff00]' : 'bg-orange-500',
|
||||
'inline-flex w-2 h-2 rounded-full',
|
||||
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'}>
|
||||
{citrine.reachable ? 'Citrine relay detected' : 'Citrine not detected'}
|
||||
<span
|
||||
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>
|
||||
<button
|
||||
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>
|
||||
</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.
|
||||
{/* LIVE: benefit chips — the reward for connecting */}
|
||||
{state === 'live' && (
|
||||
<div className="flex flex-wrap gap-1.5 animate-pop-in">
|
||||
{[
|
||||
{ icon: Zap, label: 'Instant reads' },
|
||||
{ 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>
|
||||
<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"
|
||||
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" />
|
||||
</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>
|
||||
{/* Privacy reassurance — always present when enabled */}
|
||||
<div className="flex items-start gap-2 rounded-xl bg-black/20 border border-white/5 p-2.5">
|
||||
<Shield className="w-3.5 h-3.5 text-white/40 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] leading-relaxed text-muted-foreground/70">
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -107,11 +107,45 @@ export default {
|
|||
'94%, 98%': {
|
||||
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: {
|
||||
'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