mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-09 21:58:41 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { ArrowLeft, Send, MapPin, Loader2, Brain, Trash2, Sparkles, Link2, Copy, Check, X } from 'lucide-react';
|
||||
import { getBackendEndpoint } from '@/lib/backendEndpoint';
|
||||
|
||||
interface AIMessage {
|
||||
role: 'user' | 'ai' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
pins?: { lat: number; lng: number; label: string }[];
|
||||
}
|
||||
|
||||
interface AIQueryViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
'What military flights are active right now?',
|
||||
'Show me recent earthquakes over magnitude 4',
|
||||
'What ships are near the Taiwan Strait?',
|
||||
'Give me a threat level assessment',
|
||||
'What are the top prediction market movers?',
|
||||
'Are there any correlation alerts?',
|
||||
'Show me satellite imagery of Tehran',
|
||||
'Get news from Ukraine',
|
||||
'What SIGINT activity is happening?',
|
||||
'Place a pin on every military base near Denver',
|
||||
];
|
||||
|
||||
export default function AIQueryView({ onBack }: AIQueryViewProps) {
|
||||
const [messages, setMessages] = useState<AIMessage[]>([
|
||||
{
|
||||
role: 'system',
|
||||
content: `🌍📡 SHADOWBROKER AI CO-PILOT ONLINE
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Connected to ShadowBroker OSINT platform.
|
||||
I can query telemetry, place pins on the map,
|
||||
search satellite imagery, aggregate news,
|
||||
and access all 30+ data layers.
|
||||
|
||||
Type a question or command to get started.
|
||||
Use "help" to see capabilities.`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showConnect, setShowConnect] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const apiEndpoint = getBackendEndpoint();
|
||||
|
||||
const handleCopy = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const processQuery = useCallback(async (query: string) => {
|
||||
const lowerQuery = query.toLowerCase().trim();
|
||||
|
||||
// Handle built-in commands
|
||||
if (lowerQuery === 'help') {
|
||||
return {
|
||||
content: `🌍🔍 AVAILABLE COMMANDS:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
TELEMETRY QUERIES:
|
||||
• "military flights" — Active military aircraft
|
||||
• "ships" — Tracked vessels
|
||||
• "satellites" — Orbital assets
|
||||
• "earthquakes" — Recent seismic activity
|
||||
• "threat level" — Current threat assessment
|
||||
• "prediction markets" — Market consensus data
|
||||
• "sigint" — RF signal intelligence totals
|
||||
• "correlations" — Cross-layer alerts
|
||||
|
||||
INTELLIGENCE:
|
||||
• "report" — Full intelligence report
|
||||
• "summary" — Quick telemetry summary
|
||||
• "news summary" — AI news brief with top stories & trends
|
||||
• "correlations" — Explain cross-layer correlation alerts
|
||||
• "news [place]" — News near a location
|
||||
• "satellite images [place]" — Sentinel-2 imagery
|
||||
|
||||
PIN COMMANDS:
|
||||
• "pin [lat] [lng] [label]" — Place a pin
|
||||
• "clear pins" — Clear all AI pins
|
||||
• "list pins" — Show current pins
|
||||
|
||||
TIME MACHINE:
|
||||
• "snapshot" — Take a telemetry snapshot
|
||||
• "snapshots" — List available snapshots
|
||||
• "timemachine config" — View snapshot settings
|
||||
|
||||
SYSTEM:
|
||||
• "status" — AI system status
|
||||
• "clear" — Clear chat history
|
||||
• "help" — This message`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'clear') {
|
||||
setMessages([{
|
||||
role: 'system',
|
||||
content: '🌍✅ Chat cleared. Ready for queries.',
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// API queries
|
||||
try {
|
||||
const base = '/api/ai';
|
||||
|
||||
if (lowerQuery === 'status') {
|
||||
const resp = await fetch(`${base}/status`);
|
||||
const data = await resp.json();
|
||||
return {
|
||||
content: `🌍✅ SHADOWBROKER AI STATUS:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Status: ${data.status || 'ONLINE'}
|
||||
Capabilities: ${(data.capabilities || []).join(', ')}
|
||||
Pin Count: ${data.pin_count ?? 'N/A'}
|
||||
Version: ${data.version || '1.0'}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'summary' || lowerQuery === 'quick summary') {
|
||||
const resp = await fetch(`${base}/summary`);
|
||||
const data = await resp.json();
|
||||
const counts = data.layer_counts || {};
|
||||
const lines = Object.entries(counts)
|
||||
.filter(([, v]) => (v as number) > 0)
|
||||
.map(([k, v]) => ` • ${k}: ${v}`)
|
||||
.join('\n');
|
||||
return {
|
||||
content: `🌍📡 TELEMETRY SUMMARY:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${lines || ' No active telemetry data.'}
|
||||
|
||||
Threat Level: ${data.threat_level || 'N/A'}
|
||||
SIGINT Totals: ${JSON.stringify(data.sigint_totals || {}, null, 0)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'report' || lowerQuery === 'intelligence report') {
|
||||
const resp = await fetch(`${base}/report`);
|
||||
const data = await resp.json();
|
||||
return {
|
||||
content: `🌍🛰️ INTELLIGENCE REPORT:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${data.report || JSON.stringify(data, null, 2)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery.startsWith('pin ')) {
|
||||
const parts = lowerQuery.replace('pin ', '').split(/\s+/);
|
||||
if (parts.length >= 3) {
|
||||
const lat = parseFloat(parts[0]);
|
||||
const lng = parseFloat(parts[1]);
|
||||
const label = parts.slice(2).join(' ');
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
const resp = await fetch(`${base}/pins`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lat, lng, label, category: 'research' }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
return {
|
||||
content: `🌍📌 SHADOWBROKER PINNING:
|
||||
Pin placed successfully!
|
||||
📍 ${lat.toFixed(4)}°, ${lng.toFixed(4)}°
|
||||
🏷️ ${label}
|
||||
🆔 ${data.pin_id || 'assigned'}`,
|
||||
pins: [{ lat, lng, label }],
|
||||
};
|
||||
}
|
||||
}
|
||||
return { content: '❌ Usage: pin [latitude] [longitude] [label]' };
|
||||
}
|
||||
|
||||
if (lowerQuery === 'list pins' || lowerQuery === 'pins') {
|
||||
const resp = await fetch(`${base}/pins`);
|
||||
const data = await resp.json();
|
||||
const pinList = (data.pins || [])
|
||||
.slice(0, 20)
|
||||
.map((p: { label: string; lat: number; lng: number; category: string }) =>
|
||||
` 📍 ${p.label} (${p.lat.toFixed(2)}°, ${p.lng.toFixed(2)}°) [${p.category}]`
|
||||
)
|
||||
.join('\n');
|
||||
return {
|
||||
content: `🌍📌 AI INTEL PINS (${data.count || 0}):
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${pinList || ' No pins placed yet.'}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'clear pins') {
|
||||
await fetch(`${base}/pins`, { method: 'DELETE' });
|
||||
return { content: '🌍❌ SHADOWBROKER CLEARING:\nAll AI intel pins cleared.' };
|
||||
}
|
||||
|
||||
if (lowerQuery === 'snapshot' || lowerQuery === 'take snapshot') {
|
||||
const resp = await fetch(`${base}/timemachine/snapshot`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
return {
|
||||
content: `🌍🕰️ SHADOWBROKER TIMEMACHINE:
|
||||
Snapshot taken!
|
||||
🆔 ${data.snapshot_id}
|
||||
🕐 ${data.timestamp}
|
||||
📊 Layers: ${(data.layers || []).join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'snapshots' || lowerQuery === 'list snapshots') {
|
||||
const resp = await fetch(`${base}/timemachine/snapshots`);
|
||||
const data = await resp.json();
|
||||
const snapList = (data.snapshots || [])
|
||||
.slice(0, 10)
|
||||
.map((s: { id: string; timestamp: string; layers: string[] }) =>
|
||||
` 🗂️ ${s.id} — ${s.timestamp} (${s.layers.length} layers)`
|
||||
)
|
||||
.join('\n');
|
||||
return {
|
||||
content: `🌍🕰️ TIME MACHINE SNAPSHOTS (${data.count || 0}):
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${snapList || ' No snapshots taken yet.'}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'timemachine config' || lowerQuery === 'tm config') {
|
||||
const resp = await fetch(`${base}/timemachine/config`);
|
||||
const data = await resp.json();
|
||||
const cfg = data.config || {};
|
||||
return {
|
||||
content: `🌍🕰️ TIME MACHINE CONFIG:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Preset: ${cfg.preset || 'active'}
|
||||
|
||||
High-Frequency (${cfg.profiles?.high_freq?.interval_minutes || 15}min):
|
||||
${(cfg.profiles?.high_freq?.layers || []).join(', ')}
|
||||
|
||||
Standard (${cfg.profiles?.standard?.interval_minutes || 120}min):
|
||||
${(cfg.profiles?.standard?.layers || []).join(', ')}
|
||||
|
||||
Available presets: paranoid (5min), active (15min), casual (1hr), minimal (6hr)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'news summary' || lowerQuery === 'news brief' || lowerQuery === 'ai brief') {
|
||||
const resp = await fetch(`${base}/news/summary`);
|
||||
const data = await resp.json();
|
||||
const topStories = (data.top_stories || [])
|
||||
.slice(0, 5)
|
||||
.map((s: { risk_score: number; title: string; source: string }) =>
|
||||
` [${s.risk_score}/10] ${s.title} — ${s.source}`
|
||||
)
|
||||
.join('\n');
|
||||
const keywords = (data.keywords || [])
|
||||
.slice(0, 8)
|
||||
.map((kw: { word: string; count: number }) => `${kw.word}(${kw.count})`)
|
||||
.join(', ');
|
||||
const td = data.threat_distribution || {};
|
||||
return {
|
||||
content: `🌍📰 AI INTELLIGENCE BRIEF:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${data.summary || 'No data available.'}
|
||||
|
||||
TOP STORIES:
|
||||
${topStories || ' None available.'}
|
||||
|
||||
TRENDING: ${keywords || 'N/A'}
|
||||
|
||||
THREAT DISTRIBUTION:
|
||||
🔴 CRITICAL: ${td.CRITICAL || 0} 🟠 HIGH: ${td.HIGH || 0} 🟡 ELEVATED: ${td.ELEVATED || 0}
|
||||
🔵 MODERATE: ${td.MODERATE || 0} 🟢 LOW: ${td.LOW || 0}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'correlations' || lowerQuery === 'explain correlations' || lowerQuery === 'correlation alerts') {
|
||||
const resp = await fetch(`${base}/correlations/explain`);
|
||||
const data = await resp.json();
|
||||
if (!data.count) {
|
||||
return { content: '🌍⚡ CORRELATIONS:\nNo cross-layer correlation alerts are currently active.' };
|
||||
}
|
||||
const alerts = (data.explanations || [])
|
||||
.slice(0, 8)
|
||||
.map((e: { label: string; severity_text: string; driver_summary: string; implications: string[]; recommended_action: string; lat: number; lng: number }) =>
|
||||
`━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📍 ${e.label}
|
||||
Location: ${e.lat.toFixed(2)}°, ${e.lng.toFixed(2)}°
|
||||
Severity: ${e.severity_text}
|
||||
Indicators: ${e.driver_summary}
|
||||
Assessment: ${e.implications?.[0] || 'N/A'}
|
||||
Action: ${e.recommended_action}`
|
||||
)
|
||||
.join('\n');
|
||||
return {
|
||||
content: `🌍⚡ CORRELATION ANALYSIS (${data.count} alerts):
|
||||
${data.summary || ''}
|
||||
|
||||
${alerts}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Generic fallback — try summary
|
||||
return {
|
||||
content: `🌍🔍 SHADOWBROKER SEARCHING:
|
||||
Processing query: "${query}"
|
||||
|
||||
I can directly execute these commands:
|
||||
• summary / report / status
|
||||
• pin [lat] [lng] [label]
|
||||
• list pins / clear pins
|
||||
• snapshot / snapshots / timemachine config
|
||||
• help
|
||||
|
||||
For complex queries (natural language research, web search,
|
||||
multi-step investigations), connect OpenClaw with an LLM
|
||||
provider to unlock full agent capabilities.
|
||||
|
||||
Type "help" for the full command list.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: `🌍⚠️ SHADOWBROKER WARNING:
|
||||
Query failed: ${error instanceof Error ? error.message : 'Unknown error'}
|
||||
Make sure the ShadowBroker backend is running on localhost:8000.`,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const query = input.trim();
|
||||
if (!query || isLoading) return;
|
||||
|
||||
setInput('');
|
||||
setMessages(prev => [...prev, { role: 'user', content: query, timestamp: Date.now() }]);
|
||||
setIsLoading(true);
|
||||
|
||||
const result = await processQuery(query);
|
||||
if (result) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'ai',
|
||||
content: result.content,
|
||||
timestamp: Date.now(),
|
||||
pins: result.pins,
|
||||
}]);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [input, isLoading, processQuery]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#0a0a0a] text-gray-300">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-purple-900/40 bg-purple-950/10 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title="Back to terminal"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<Brain size={18} className="text-purple-400" />
|
||||
<span className="text-sm tracking-[0.2em] text-purple-400 uppercase font-bold">
|
||||
AI Co-Pilot
|
||||
</span>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_6px_rgba(34,197,94,0.6)]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowConnect(!showConnect)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-bold tracking-wider uppercase transition-all rounded-sm ${
|
||||
showConnect
|
||||
? 'bg-purple-900/40 border border-purple-500/50 text-purple-300'
|
||||
: 'bg-purple-900/20 border border-purple-800/30 text-purple-500 hover:bg-purple-900/30 hover:text-purple-300 hover:border-purple-600/40'
|
||||
}`}
|
||||
title="Connect your OpenClaw agent"
|
||||
>
|
||||
<Link2 size={13} />
|
||||
Connect OpenClaw
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMessages([{
|
||||
role: 'system',
|
||||
content: '🌍✅ Chat cleared. Ready for queries.',
|
||||
timestamp: Date.now(),
|
||||
}])}
|
||||
className="text-gray-600 hover:text-red-400 transition-colors"
|
||||
title="Clear chat"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connect OpenClaw Panel */}
|
||||
{showConnect && (
|
||||
<div className="border-b border-purple-900/40 bg-purple-950/15 px-4 py-4 shrink-0 overflow-y-auto max-h-[60vh]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-purple-400" />
|
||||
<span className="text-sm font-bold tracking-wider text-purple-400 uppercase">Connect Your OpenClaw Agent</span>
|
||||
</div>
|
||||
<button onClick={() => setShowConnect(false)} className="text-gray-600 hover:text-gray-300 transition-colors">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm font-mono">
|
||||
{/* API Endpoint */}
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Your ShadowBroker API Endpoint</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-black/60 border border-purple-800/40 px-3 py-2 text-purple-300 text-sm rounded-sm select-all">
|
||||
{apiEndpoint}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopy(apiEndpoint)}
|
||||
className="p-2 bg-purple-900/30 border border-purple-800/40 text-purple-400 hover:bg-purple-900/50 hover:text-purple-200 transition-colors rounded-sm"
|
||||
title="Copy endpoint"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup Instructions */}
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Setup Instructions</div>
|
||||
<div className="bg-black/60 border border-gray-800/40 rounded-sm p-3 space-y-2 text-[13px] leading-relaxed">
|
||||
<p className="text-cyan-400 font-bold">Step 1: Install the ShadowBroker Skill</p>
|
||||
<p className="text-gray-400">Copy the <code className="text-purple-300 bg-purple-900/30 px-1">openclaw-skills/shadowbroker/</code> folder into your OpenClaw's skills directory.</p>
|
||||
|
||||
<p className="text-cyan-400 font-bold mt-2">Step 2: Configure the API Endpoint</p>
|
||||
<p className="text-gray-400">Tell your OpenClaw agent to connect to:</p>
|
||||
<code className="block bg-purple-950/40 border border-purple-800/30 px-2 py-1 text-purple-300 text-[13px] rounded-sm">
|
||||
SHADOWBROKER_URL={apiEndpoint}
|
||||
</code>
|
||||
|
||||
<p className="text-cyan-400 font-bold mt-2">Step 3: Tell Your Agent</p>
|
||||
<p className="text-gray-400">Paste this into your OpenClaw's system prompt or instructions:</p>
|
||||
<div className="relative">
|
||||
<pre className="bg-purple-950/40 border border-purple-800/30 px-2 py-2 text-[12px] text-purple-200 rounded-sm overflow-x-auto whitespace-pre-wrap">{`You have a skill called "shadowbroker" that connects you to a real-time global OSINT intelligence platform. Use it to:
|
||||
- Query military flights, ships, satellites, SIGINT, earthquakes, and 30+ data layers
|
||||
- Place intelligence pins on a live map
|
||||
- Fetch satellite imagery from Sentinel-2
|
||||
- Aggregate news by region via GDELT
|
||||
- Take telemetry snapshots (Time Machine)
|
||||
- Participate in the Wormhole encrypted mesh network
|
||||
- Send/receive InfoNet messages via decentralized feed
|
||||
|
||||
API: ${apiEndpoint}
|
||||
Skill docs: openclaw-skills/shadowbroker/SKILL.md`}</pre>
|
||||
<button
|
||||
onClick={() => handleCopy(`You have a skill called "shadowbroker" that connects you to a real-time global OSINT intelligence platform. Use it to:\n- Query military flights, ships, satellites, SIGINT, earthquakes, and 30+ data layers\n- Place intelligence pins on a live map\n- Fetch satellite imagery from Sentinel-2\n- Aggregate news by region via GDELT\n- Take telemetry snapshots (Time Machine)\n- Participate in the Wormhole encrypted mesh network\n- Send/receive InfoNet messages via decentralized feed\n\nAPI: ${apiEndpoint}\nSkill docs: openclaw-skills/shadowbroker/SKILL.md`)}
|
||||
className="absolute top-1 right-1 p-1 bg-purple-900/50 text-purple-400 hover:text-purple-200 transition-colors rounded-sm"
|
||||
title="Copy instructions"
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Capabilities */}
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Available Capabilities</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{[
|
||||
['📡', 'Telemetry Queries'],
|
||||
['📌', 'Pin Placement'],
|
||||
['🛰️', 'Satellite Imagery'],
|
||||
['📰', 'News Aggregation'],
|
||||
['🕰️', 'Time Machine'],
|
||||
['🔗', 'Wormhole Network'],
|
||||
['📻', 'Meshtastic Radio'],
|
||||
['💉', 'Data Injection'],
|
||||
['⚡', 'Correlation Analysis'],
|
||||
['🚨', 'Alert Dispatch'],
|
||||
].map(([emoji, label]) => (
|
||||
<div key={label} className="flex items-center gap-1.5 text-[12px] text-gray-400 bg-black/30 border border-gray-800/30 px-2 py-1 rounded-sm">
|
||||
<span>{emoji}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] px-3 py-2.5 text-[13px] leading-relaxed whitespace-pre-wrap ${
|
||||
msg.role === 'user'
|
||||
? 'bg-purple-900/30 border border-purple-700/40 text-purple-100'
|
||||
: msg.role === 'system'
|
||||
? 'bg-cyan-950/20 border border-cyan-900/30 text-cyan-300'
|
||||
: 'bg-gray-900/40 border border-gray-800/40 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
{msg.pins && msg.pins.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-700/30 flex items-center gap-1.5 text-green-400 text-sm">
|
||||
<MapPin size={12} />
|
||||
<span>{msg.pins.length} pin(s) placed on map</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-900/40 border border-gray-800/40 px-3 py-2.5 flex items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>Processing query...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Quick suggestions */}
|
||||
{messages.length <= 2 && (
|
||||
<div className="px-4 pb-2 flex flex-wrap gap-1.5">
|
||||
{EXAMPLE_QUERIES.slice(0, 4).map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => { setInput(q); inputRef.current?.focus(); }}
|
||||
className="text-xs px-2.5 py-1 bg-purple-900/15 border border-purple-800/30 text-purple-400 hover:bg-purple-900/30 hover:text-purple-300 transition-colors flex items-center gap-1.5 rounded-sm"
|
||||
>
|
||||
<Sparkles size={10} />
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 px-4 py-3 border-t border-purple-900/30 bg-purple-950/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 flex items-center bg-gray-900/40 border border-gray-700/40 focus-within:border-purple-700/60 transition-colors rounded-sm">
|
||||
<span className="text-purple-500 text-sm px-2.5 select-none">❯</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything... (type 'help' for commands)"
|
||||
className="flex-1 bg-transparent border-none outline-none text-white text-sm py-2.5 pr-2 placeholder-gray-600 focus:ring-0"
|
||||
disabled={isLoading}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="p-2 bg-purple-900/30 border border-purple-700/40 text-purple-400 hover:bg-purple-900/50 hover:text-purple-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, Cpu, Loader, AlertCircle, CheckCircle2, XCircle, Server } from 'lucide-react';
|
||||
import {
|
||||
buildBootstrapResolutionVotePayload,
|
||||
fetchBootstrapMarketState,
|
||||
fetchInfonetStatus,
|
||||
type BootstrapMarketState,
|
||||
type InfonetStatus,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { generateNodeKeys, getNodeIdentity } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
DEFAULT_INFONET_SEED_URL,
|
||||
fetchInfonetNodeStatusSnapshot,
|
||||
setInfonetNodeEnabled,
|
||||
type InfonetNodeStatusSnapshot,
|
||||
} from '@/mesh/controlPlaneStatusClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface BootstrapViewProps {
|
||||
marketId?: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function BootstrapView({ marketId, onBack }: BootstrapViewProps) {
|
||||
const [status, setStatus] = useState<InfonetStatus | null>(null);
|
||||
const [market, setMarket] = useState<BootstrapMarketState | null>(null);
|
||||
const [nodeStatus, setNodeStatus] = useState<InfonetNodeStatusSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nodeToggleBusy, setNodeToggleBusy] = useState(false);
|
||||
const [nodeToggleError, setNodeToggleError] = useState<string | null>(null);
|
||||
const [voteSide, setVoteSide] = useState<'yes' | 'no'>('yes');
|
||||
const [powNonce, setPowNonce] = useState('0');
|
||||
const voteAction = useSignAndAppend();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [s, m, n] = await Promise.all([
|
||||
fetchInfonetStatus(),
|
||||
marketId ? fetchBootstrapMarketState(marketId).catch(() => null) : Promise.resolve(null),
|
||||
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
||||
]);
|
||||
setStatus(s);
|
||||
setMarket(m);
|
||||
setNodeStatus(n);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [marketId]);
|
||||
|
||||
const nodeEnabled = Boolean(nodeStatus?.node_enabled);
|
||||
const nodeMode = String(nodeStatus?.node_mode || 'participant').toUpperCase();
|
||||
const syncOutcome = String(nodeStatus?.sync_runtime?.last_outcome || 'idle').toLowerCase();
|
||||
const seedPeerCount = Number(nodeStatus?.bootstrap?.default_sync_peer_count || 0);
|
||||
const syncPeerCount = Number(nodeStatus?.bootstrap?.sync_peer_count || 0);
|
||||
const lastPeerUrl = String(nodeStatus?.sync_runtime?.last_peer_url || '').trim();
|
||||
|
||||
const toggleNode = useCallback(async (enabled: boolean) => {
|
||||
setNodeToggleBusy(true);
|
||||
setNodeToggleError(null);
|
||||
try {
|
||||
if (enabled && !getNodeIdentity()) {
|
||||
await generateNodeKeys();
|
||||
}
|
||||
await setInfonetNodeEnabled(enabled);
|
||||
const next = await fetchInfonetNodeStatusSnapshot(true);
|
||||
setNodeStatus(next);
|
||||
} catch (err) {
|
||||
setNodeToggleError(err instanceof Error ? err.message : 'node settings update failed');
|
||||
} finally {
|
||||
setNodeToggleBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasActivePhase = !!market && market.tally.total_eligible >= 0
|
||||
&& market.tally.yes + market.tally.no < market.tally.total_eligible;
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
const submitVote = useCallback(async () => {
|
||||
if (!marketId) return;
|
||||
const nonce = Number(powNonce);
|
||||
if (!Number.isFinite(nonce) || nonce < 0) return;
|
||||
const built = buildBootstrapResolutionVotePayload(marketId, voteSide, Math.floor(nonce));
|
||||
const res = await voteAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
void reload();
|
||||
}
|
||||
}, [marketId, voteSide, powNonce, voteAction, reload]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
|
||||
<ChevronLeft size={14} className="mr-1" /> BACK
|
||||
</button>
|
||||
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Cpu size={16} /> BOOTSTRAP MODE
|
||||
</div>
|
||||
<button onClick={() => void reload()} disabled={loading} className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30">
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
The first <span className="text-cyan-400">bootstrap_market_count</span> (default 100) markets
|
||||
resolve via <span className="text-cyan-400">eligible-node-one-vote</span> instead of oracle-rep-weighted
|
||||
staking. Eligibility: identity age ≥ 3 days vs market.snapshot.frozen_at,
|
||||
NOT in the predictor exclusion set, and a valid Argon2id PoW
|
||||
(Heavy-Node-only — requires ≥64MB RAM per computation).
|
||||
Once node count crosses <span className="text-cyan-400">bootstrap_threshold</span> (default 1000),
|
||||
new markets default to staked resolution. Existing bootstrap-indexed markets continue under
|
||||
bootstrap rules until they resolve.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<AlertCircle size={12} className="inline mr-1" />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-cyan-900/50 bg-cyan-950/10 p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 flex items-center gap-2">
|
||||
<Server size={14} /> Network Seed
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
className="text-[10px] text-gray-500 hover:text-cyan-400 disabled:opacity-30 uppercase tracking-widest"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Default Seed</div>
|
||||
<div className="text-cyan-300 font-mono break-all">{DEFAULT_INFONET_SEED_URL}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Local Node</div>
|
||||
<div className={nodeEnabled ? 'text-green-400' : 'text-gray-500'}>
|
||||
{nodeEnabled ? `${nodeMode} ONLINE` : `${nodeMode} OFF`}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Sync Path</div>
|
||||
<div className="text-white font-mono">
|
||||
{syncPeerCount} peers / {seedPeerCount} default
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div className="flex-1 text-[11px] text-gray-500 leading-relaxed">
|
||||
{nodeEnabled
|
||||
? `Public chain sync is ${syncOutcome || 'active'}${lastPeerUrl ? ` via ${lastPeerUrl}` : ''}.`
|
||||
: 'Start a local participant node to pull from the default seed and help carry the public Infonet chain while this backend is running.'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleNode(!nodeEnabled)}
|
||||
disabled={nodeToggleBusy}
|
||||
className={
|
||||
nodeEnabled
|
||||
? 'px-3 py-2 border border-rose-700/50 bg-rose-950/20 text-rose-300 hover:bg-rose-950/35 disabled:opacity-40 text-[10px] uppercase tracking-wider'
|
||||
: 'px-3 py-2 border border-cyan-700/50 bg-cyan-900/20 text-cyan-300 hover:bg-cyan-900/40 disabled:opacity-40 text-[10px] uppercase tracking-wider'
|
||||
}
|
||||
>
|
||||
{nodeToggleBusy ? 'Updating...' : nodeEnabled ? 'Turn Off Node' : 'Start Node'}
|
||||
</button>
|
||||
</div>
|
||||
{nodeToggleError && (
|
||||
<div className="mt-3 border border-amber-900/50 bg-amber-950/20 p-2 text-[11px] text-amber-300">
|
||||
<AlertCircle size={11} className="inline mr-1" />{nodeToggleError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">Network Ramp</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Distinct Nodes</div>
|
||||
<div className="text-white font-mono text-lg">{status.ramp.node_count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Bootstrap Resolution</div>
|
||||
<div className={status.ramp.bootstrap_resolution_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.bootstrap_resolution_active ? 'ACTIVE' : 'TRANSITIONED'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Staked Resolution</div>
|
||||
<div className={status.ramp.staked_resolution_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.staked_resolution_active ? 'ACTIVE' : 'LOCKED'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Petitions</div>
|
||||
<div className={status.ramp.governance_petitions_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.governance_petitions_active ? 'ACTIVE' : 'LOCKED'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Upgrade Governance</div>
|
||||
<div className={status.ramp.upgrade_governance_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.upgrade_governance_active ? 'ACTIVE' : 'LOCKED'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">CommonCoin</div>
|
||||
<div className={status.ramp.commoncoin_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.commoncoin_active ? 'ACTIVE' : 'LOCKED'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{market && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">
|
||||
Market: <span className="font-mono text-white">{market.market_id}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs mb-3">
|
||||
<div>
|
||||
<div className="text-gray-500">YES votes</div>
|
||||
<div className="text-green-400 font-mono text-lg">{market.tally.yes}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">NO votes</div>
|
||||
<div className="text-red-400 font-mono text-lg">{market.tally.no}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Total Eligible</div>
|
||||
<div className="text-white font-mono text-lg">{market.tally.total_eligible}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Min Required</div>
|
||||
<div className="text-gray-300 font-mono text-lg">{market.tally.min_market_participants}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-cyan-900/50 bg-cyan-900/10 p-2 mb-3 text-xs">
|
||||
<div className="text-cyan-400 font-bold uppercase tracking-wider mb-2">
|
||||
Cast Bootstrap Vote
|
||||
</div>
|
||||
<div className="text-gray-500 mb-2">
|
||||
Eligibility: identity age ≥{' '}
|
||||
{status ? '3 days' : 'configured threshold'}{' '}
|
||||
vs market.snapshot.frozen_at, NOT in predictor exclusion set,
|
||||
and a valid Argon2id PoW (Heavy-Node-only). The PoW nonce
|
||||
input is for testnet — production wires the Argon2id solver
|
||||
via privacy-core when the Rust binding lands.
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={voteSide}
|
||||
onChange={(e) => setVoteSide(e.target.value as 'yes' | 'no')}
|
||||
title="Bootstrap vote side"
|
||||
aria-label="Bootstrap vote side"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="yes">YES</option>
|
||||
<option value="no">NO</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={powNonce}
|
||||
onChange={(e) => setPowNonce(e.target.value)}
|
||||
placeholder="pow_nonce"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitVote}
|
||||
disabled={voteAction.state === 'submitting' || !marketId}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
|
||||
>
|
||||
{voteAction.state === 'submitting' ? 'Submitting…' : 'Cast Vote'}
|
||||
</button>
|
||||
</div>
|
||||
{voteAction.result && !voteAction.result.ok && (
|
||||
<div className="text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{voteAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs uppercase tracking-wider text-gray-500 mb-2">All Submitted Votes</div>
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{market.votes.map((v) => (
|
||||
<div key={v.node_id} className="flex items-center justify-between gap-2 text-xs border-b border-gray-800/30 py-1">
|
||||
<span className="font-mono text-gray-400 truncate flex-1">{v.node_id.slice(0, 16)}…</span>
|
||||
<span className={v.side === 'yes' ? 'text-green-400' : 'text-red-400'}>{v.side?.toUpperCase()}</span>
|
||||
<span className="w-20 text-right">
|
||||
{v.eligible ? (
|
||||
<CheckCircle2 size={12} className="text-green-400 inline" />
|
||||
) : (
|
||||
<span className="text-amber-400 flex items-center justify-end gap-1">
|
||||
<XCircle size={12} />
|
||||
<span className="text-xs">{v.ineligible_reason}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!marketId && (
|
||||
<div className="border border-gray-800 bg-black/40 p-6 text-center text-xs text-gray-500">
|
||||
Open a bootstrap-indexed market from the Markets view to see its
|
||||
eligible-node-one-vote tally here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, KeyRound, ShieldCheck, AlertTriangle, FileKey } from 'lucide-react';
|
||||
import { fetchInfonetStatus, type InfonetStatus } from '@/mesh/infonetEconomyClient';
|
||||
|
||||
interface FunctionKeyViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const PIECE_STATUS: Record<string, { color: string; label: string }> = {
|
||||
not_implemented: { color: 'text-gray-500', label: 'NOT IMPLEMENTED' },
|
||||
scaffolding: { color: 'text-amber-400', label: 'SCAFFOLDING' },
|
||||
reference_impl: { color: 'text-blue-400', label: 'REFERENCE' },
|
||||
production_rust: { color: 'text-green-400', label: 'PRODUCTION' },
|
||||
};
|
||||
|
||||
export default function FunctionKeyView({ onBack }: FunctionKeyViewProps) {
|
||||
const [status, setStatus] = useState<InfonetStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const s = await fetchInfonetStatus();
|
||||
if (!cancelled) setStatus(s);
|
||||
} catch {
|
||||
// ignore — render the design overview without status
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
|
||||
<ChevronLeft size={14} className="mr-1" /> BACK
|
||||
</button>
|
||||
<div className="text-sm text-purple-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<KeyRound size={16} /> FUNCTION KEYS — Anonymous Citizenship Proof
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
<div className="text-xs text-gray-400 leading-relaxed">
|
||||
A citizen proves "I am an Infonet citizen" to a real-world
|
||||
operator <span className="text-purple-400">without revealing their Infonet identity</span>.
|
||||
The naive approach (scramble a public key, record each redemption on chain) leaks
|
||||
identity through metadata correlation. The Function Keys design is six pieces;
|
||||
five are implemented; one (issuance via blind signatures / anonymous credentials)
|
||||
waits on a cryptographic primitive decision.
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-purple-400 mb-2 flex items-center gap-1">
|
||||
<ShieldCheck size={12} /> Privacy Primitive Status
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
{Object.entries(status.privacy_primitive_status).map(([k, v]) => {
|
||||
const style = PIECE_STATUS[v] ?? PIECE_STATUS.not_implemented;
|
||||
return (
|
||||
<div key={k}>
|
||||
<div className="text-gray-500 capitalize">{k.replace(/_/g, ' ')}</div>
|
||||
<div className={`${style.color} font-bold`}>{style.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Cryptographic primitives are stubbed via the locked Protocol contracts in
|
||||
<span className="font-mono"> services/infonet/privacy/contracts.py</span>.
|
||||
When the privacy-core Rust binding lands, the scaffolding swaps for the
|
||||
production class — no caller changes.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-purple-400 mb-2">
|
||||
The Six Pieces
|
||||
</div>
|
||||
<ol className="text-xs space-y-2 text-gray-300">
|
||||
<li>
|
||||
<span className="text-amber-400 font-bold">1. Issuance</span>{' '}
|
||||
<span className="text-gray-500">(NOT IMPLEMENTED — blind sig / BBS+ / U-Prove / Idemix)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Protocol issues a credential proving citizenship without linking to node_id.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">2. Nullifiers</span>{' '}
|
||||
<span className="text-gray-500">(implemented — pure SHA-256)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
<span className="font-mono">nullifier = H(secret || operator_id)</span>.
|
||||
Different operators see different nullifiers for the same key — no
|
||||
cross-operator linkage. One-time-use per (key, operator) pair via a
|
||||
tracker.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">3. Challenge-Response</span>{' '}
|
||||
<span className="text-gray-500">(implemented — HMAC-SHA256 placeholder)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Operator issues a fresh nonce; key-holder signs with the Function Key's
|
||||
secret. Defeats screenshot, replay, key-sharing. Production wires the chosen
|
||||
blind-sig scheme; API stays compatible.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">4. Two-Phase Commit Receipts</span>{' '}
|
||||
<span className="text-gray-500">(implemented)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Phase 1: operator signs a verification receipt (day-bucket date,
|
||||
nullifier prefix only — NO timestamps, NO full nullifiers, NO node_id).
|
||||
Phase 2: citizen counter-signs after service rendered. Both parties hold
|
||||
a copy. <span className="text-purple-400">Receipts NEVER published on-chain.</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">5. Enumerated Denial Codes</span>{' '}
|
||||
<span className="text-gray-500">(implemented — 3-value enum)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Operators can reject for exactly three reasons: invalid signature,
|
||||
nullifier already seen, rate limit exceeded. Adding a 4th code is a hard
|
||||
fork. Anti-discrimination by design.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">6. Batched Settlement</span>{' '}
|
||||
<span className="text-gray-500">(implemented)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Operators settle in aggregate. Chain sees{' '}
|
||||
<span className="font-mono">{operator_id, period_id, count}</span>{' '}
|
||||
— never per-receipt detail. Fraud detection via statistical auditing,
|
||||
not per-redemption traces.
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="border border-amber-900/50 bg-amber-900/10 p-3">
|
||||
<div className="flex items-center gap-2 text-amber-400 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<AlertTriangle size={12} /> Production Readiness
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 space-y-1">
|
||||
<div>
|
||||
<FileKey size={11} className="inline mr-1" />
|
||||
The HMAC-SHA256 placeholder requires the verifier to know the citizen's
|
||||
secret — that is NOT private. Production replaces it with a blind-sig
|
||||
scheme that verifies without learning the secret.
|
||||
</div>
|
||||
<div>
|
||||
The cryptographic scheme decision (RSA blind sigs vs BBS+ vs U-Prove vs
|
||||
Idemix) is open per IMPLEMENTATION_PLAN §6.4.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, AlertTriangle, Lock, Clock, ShieldOff, Loader, CheckCircle2 } from 'lucide-react';
|
||||
import {
|
||||
buildGateShutdownAppealFilePayload,
|
||||
buildGateShutdownFilePayload,
|
||||
buildGateSuspendFilePayload,
|
||||
fetchGateState,
|
||||
freshLocalId,
|
||||
type GateState,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface GateShutdownViewProps {
|
||||
gateId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, { color: string; label: string; icon: typeof Lock }> = {
|
||||
active: { color: 'text-green-400', label: 'ACTIVE', icon: CheckCircle2 },
|
||||
suspended: { color: 'text-amber-400', label: 'SUSPENDED', icon: Clock },
|
||||
shutdown: { color: 'text-red-500', label: 'SHUTDOWN', icon: ShieldOff },
|
||||
};
|
||||
|
||||
function formatTs(ts: number | null): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatRelative(ts: number | null, now: number): string {
|
||||
if (!ts) return '—';
|
||||
const delta = ts - now;
|
||||
const abs = Math.abs(delta);
|
||||
const days = Math.floor(abs / 86400);
|
||||
const hours = Math.floor((abs % 86400) / 3600);
|
||||
if (delta > 0) {
|
||||
if (days > 0) return `in ${days}d ${hours}h`;
|
||||
return `in ${hours}h`;
|
||||
}
|
||||
if (days > 0) return `${days}d ago`;
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
export default function GateShutdownView({ gateId, onBack }: GateShutdownViewProps) {
|
||||
const [data, setData] = useState<GateState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filing forms — reused for suspend / shutdown / appeal.
|
||||
const [reason, setReason] = useState('');
|
||||
const [evidenceHash, setEvidenceHash] = useState('');
|
||||
const suspendAction = useSignAndAppend();
|
||||
const shutdownAction = useSignAndAppend();
|
||||
const appealAction = useSignAndAppend();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchGateState(gateId);
|
||||
if (res.ok) {
|
||||
setData(res);
|
||||
} else {
|
||||
setError(res.reason);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [gateId]);
|
||||
|
||||
const hasActivePhase = data?.suspension.status === 'suspended';
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
const status = data ? STATUS_STYLE[data.suspension.status] ?? STATUS_STYLE.active : null;
|
||||
|
||||
const fileSuspend = useCallback(async () => {
|
||||
if (!reason.trim() || !evidenceHash.trim()) return;
|
||||
const built = buildGateSuspendFilePayload(
|
||||
freshLocalId('sus'), gateId, reason.trim(), [evidenceHash.trim()],
|
||||
);
|
||||
const res = await suspendAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setReason(''); setEvidenceHash('');
|
||||
void reload();
|
||||
}
|
||||
}, [reason, evidenceHash, gateId, suspendAction, reload]);
|
||||
|
||||
const fileShutdown = useCallback(async () => {
|
||||
if (!reason.trim() || !evidenceHash.trim()) return;
|
||||
const built = buildGateShutdownFilePayload(
|
||||
freshLocalId('shd'), gateId, reason.trim(), [evidenceHash.trim()],
|
||||
);
|
||||
const res = await shutdownAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setReason(''); setEvidenceHash('');
|
||||
void reload();
|
||||
}
|
||||
}, [reason, evidenceHash, gateId, shutdownAction, reload]);
|
||||
|
||||
const fileAppeal = useCallback(async () => {
|
||||
if (!reason.trim() || !evidenceHash.trim()) return;
|
||||
if (!data?.shutdown.pending_petition_id) return;
|
||||
const built = buildGateShutdownAppealFilePayload(
|
||||
freshLocalId('app'),
|
||||
gateId,
|
||||
data.shutdown.pending_petition_id,
|
||||
reason.trim(),
|
||||
[evidenceHash.trim()],
|
||||
);
|
||||
const res = await appealAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setReason(''); setEvidenceHash('');
|
||||
void reload();
|
||||
}
|
||||
}, [reason, evidenceHash, gateId, data, appealAction, reload]);
|
||||
|
||||
const canFileSuspend = data?.suspension.status === 'active';
|
||||
const canFileShutdown = data?.suspension.status === 'suspended' && !data?.shutdown.has_pending;
|
||||
const canFileAppeal = data?.shutdown.pending_status === 'executing';
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
|
||||
<ChevronLeft size={14} className="mr-1" /> BACK
|
||||
</button>
|
||||
<div className="text-sm text-amber-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<ShieldOff size={16} /> GATE SHUTDOWN — {gateId}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
className="text-xs text-gray-500 hover:text-amber-400 disabled:opacity-30"
|
||||
>
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
Gate shutdown is two-tier: <span className="text-amber-400">SUSPEND</span> (30-day reversible freeze)
|
||||
→ <span className="text-red-400">SHUTDOWN</span> (irreversible archive, 7-day execution delay
|
||||
with one typed appeal allowed). Voting uses oracle_rep_active weight; thresholds are higher for
|
||||
locked gates (<span className="text-cyan-400">75% suspend / 80% shutdown</span> instead of 67% / 75%).
|
||||
Anti-stall: one appeal per shutdown, 48h filing window after vote passes.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<AlertTriangle size={12} className="inline mr-1" /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && status && (
|
||||
<>
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<status.icon size={14} className={status.color} />
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
{data.locked.is_locked && (
|
||||
<span className="ml-2 text-cyan-400 text-xs flex items-center gap-1">
|
||||
<Lock size={12} /> LOCKED
|
||||
</span>
|
||||
)}
|
||||
{data.ratified && (
|
||||
<span className="ml-2 text-green-400 text-xs">✓ RATIFIED</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Members</div>
|
||||
<div className="text-white">{data.members.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Cumulative Oracle Rep</div>
|
||||
<div className="text-white">{data.cumulative_member_oracle_rep.toFixed(2)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Entry Sacrifice</div>
|
||||
<div className="text-white">{data.meta.entry_sacrifice} common rep</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Min Overall Rep</div>
|
||||
<div className="text-white">{data.meta.min_overall_rep}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Created</div>
|
||||
<div className="text-white text-xs">{formatTs(data.meta.created_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Locked At</div>
|
||||
<div className="text-white text-xs">
|
||||
{data.locked.locked_at ? formatTs(data.locked.locked_at) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.suspension.status === 'suspended' && (
|
||||
<div className="border border-amber-900/50 bg-amber-900/10 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
|
||||
<Clock size={12} /> Suspension State
|
||||
</div>
|
||||
<div className="text-xs text-gray-300 space-y-1">
|
||||
<div>Suspended at: <span className="text-white">{formatTs(data.suspension.suspended_at)}</span></div>
|
||||
<div>
|
||||
Auto-unsuspends:{' '}
|
||||
<span className="text-amber-400">
|
||||
{formatRelative(data.suspension.suspended_until, data.now)} ({formatTs(data.suspension.suspended_until)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-500 mt-2">
|
||||
During suspension: no gate_message, gate_enter, gate_exit. Members retain
|
||||
membership; content preserved (append-only).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.shutdown.has_pending && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
|
||||
<ShieldOff size={12} /> Pending Shutdown Petition
|
||||
</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-gray-300">
|
||||
ID: <span className="font-mono text-white">{data.shutdown.pending_petition_id}</span>
|
||||
</div>
|
||||
<div className="text-gray-300">
|
||||
Status:{' '}
|
||||
<span className={
|
||||
data.shutdown.pending_status === 'executing' ? 'text-red-400' :
|
||||
data.shutdown.pending_status === 'appealed' ? 'text-amber-400' :
|
||||
data.shutdown.pending_status === 'voided_appeal' ? 'text-green-400' :
|
||||
'text-gray-300'
|
||||
}>
|
||||
{data.shutdown.pending_status?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{data.shutdown.execution_at && (
|
||||
<div className="text-red-400">
|
||||
Executes {formatRelative(data.shutdown.execution_at, data.now)} (
|
||||
{formatTs(data.shutdown.execution_at)})
|
||||
</div>
|
||||
)}
|
||||
{data.shutdown.pending_status === 'appealed' && (
|
||||
<div className="text-amber-400">
|
||||
⚠ Execution timer is PAUSED while appeal is voted on. If the appeal
|
||||
passes, the shutdown is voided. If it fails, the timer resumes.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.shutdown.executed && (
|
||||
<div className="border border-red-900/50 bg-red-900/20 p-3 text-xs">
|
||||
<div className="text-red-400 font-bold uppercase tracking-wider mb-1">
|
||||
GATE SHUT DOWN — IRREVERSIBLE
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
Members released. Content archived. gate_id retired. No petition can reopen.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data.shutdown.executed && (canFileSuspend || canFileShutdown || canFileAppeal) && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-gray-300 mb-2">
|
||||
File a Petition
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Reason and at least one evidence hash are required. Filing
|
||||
costs common rep (suspend: 15, shutdown: 25, appeal: 20)
|
||||
and triggers a 7-day vote window.
|
||||
{canFileSuspend && ' Suspend → 30-day reversible freeze.'}
|
||||
{canFileShutdown && ' Shutdown requires active suspension.'}
|
||||
{canFileAppeal && ' Appeal pauses the 7-day execution timer.'}
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="reason (max 2000 chars)"
|
||||
maxLength={2000}
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={evidenceHash}
|
||||
onChange={(e) => setEvidenceHash(e.target.value)}
|
||||
placeholder="evidence hash (e.g. ipfs://… or sha256:…)"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canFileSuspend && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fileSuspend}
|
||||
disabled={
|
||||
suspendAction.state === 'submitting' ||
|
||||
!reason.trim() || !evidenceHash.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
|
||||
>
|
||||
{suspendAction.state === 'submitting' ? 'Filing…' : 'File Suspend'}
|
||||
</button>
|
||||
)}
|
||||
{canFileShutdown && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fileShutdown}
|
||||
disabled={
|
||||
shutdownAction.state === 'submitting' ||
|
||||
!reason.trim() || !evidenceHash.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{shutdownAction.state === 'submitting' ? 'Filing…' : 'File Shutdown'}
|
||||
</button>
|
||||
)}
|
||||
{canFileAppeal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fileAppeal}
|
||||
disabled={
|
||||
appealAction.state === 'submitting' ||
|
||||
!reason.trim() || !evidenceHash.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
|
||||
>
|
||||
{appealAction.state === 'submitting' ? 'Filing…' : 'File Appeal'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(suspendAction.result && !suspendAction.result.ok) && (
|
||||
<div className="text-red-400 font-mono text-xs mt-2 break-all">
|
||||
<AlertTriangle size={10} className="inline mr-1" />
|
||||
{suspendAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
{(shutdownAction.result && !shutdownAction.result.ok) && (
|
||||
<div className="text-red-400 font-mono text-xs mt-2 break-all">
|
||||
<AlertTriangle size={10} className="inline mr-1" />
|
||||
{shutdownAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
{(appealAction.result && !appealAction.result.ok) && (
|
||||
<div className="text-red-400 font-mono text-xs mt-2 break-all">
|
||||
<AlertTriangle size={10} className="inline mr-1" />
|
||||
{appealAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,36 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, RefreshCw, Reply, Search, Send } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { controlPlaneJson } from '@/lib/controlPlane';
|
||||
import {
|
||||
nextGateMessagesPollDelayMs,
|
||||
nextGateMessagesWaitRearmDelayMs,
|
||||
nextGateMessagesWaitTimeoutMs,
|
||||
} from '@/mesh/gateMetadataTiming';
|
||||
import {
|
||||
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
|
||||
fetchGateMessageSnapshotState,
|
||||
waitForGateMessageSnapshot,
|
||||
} from '@/mesh/gateMessageSnapshot';
|
||||
import {
|
||||
getGateSessionStreamStatus,
|
||||
retainGateSessionStreamGate,
|
||||
subscribeGateSessionStreamEvents,
|
||||
subscribeGateSessionStreamStatus,
|
||||
} from '@/mesh/gateSessionStream';
|
||||
import { nextSequence } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
approveGateCompatFallback,
|
||||
decryptWormholeGateMessages,
|
||||
fetchWormholeGateKeyStatus,
|
||||
hasGateCompatFallbackApproval,
|
||||
postWormholeGateMessage,
|
||||
prepareWormholeInteractiveLane,
|
||||
signMeshEvent,
|
||||
syncBrowserWormholeGateState,
|
||||
type WormholeGateKeyStatus,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import { gateEnvelopeDisplayText, gateEnvelopeState, isEncryptedGateEnvelope } from '@/mesh/gateEnvelope';
|
||||
import { validateEventPayload } from '@/mesh/meshSchema';
|
||||
import { useGateSSE } from '@/hooks/useGateSSE';
|
||||
|
||||
const GATE_INTROS: Record<string, string> = {
|
||||
infonet:
|
||||
@@ -52,6 +71,8 @@ interface GateViewProps {
|
||||
onNavigateGate: (gate: string) => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
availableGates: string[];
|
||||
/** Open the gate shutdown lifecycle view. */
|
||||
onOpenShutdownPetition?: (gate: string) => void;
|
||||
}
|
||||
|
||||
interface GateMessage {
|
||||
@@ -65,6 +86,7 @@ interface GateMessage {
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
gate_envelope?: string;
|
||||
envelope_hash?: string;
|
||||
decrypted_message?: string;
|
||||
payload?: {
|
||||
gate?: string;
|
||||
@@ -73,6 +95,7 @@ interface GateMessage {
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
gate_envelope?: string;
|
||||
envelope_hash?: string;
|
||||
reply_to?: string;
|
||||
};
|
||||
gate?: string;
|
||||
@@ -93,9 +116,6 @@ interface ReplyContext {
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
const GATE_ACCESS_PROOF_TTL_MS = 45_000;
|
||||
const gateAccessHeaderCache = new Map<string, { headers: Record<string, string>; expiresAt: number }>();
|
||||
|
||||
function timeAgo(timestamp: number): string {
|
||||
const ts = Number(timestamp || 0);
|
||||
if (!ts) return 'just now';
|
||||
@@ -176,44 +196,84 @@ function normalizeGateMessage(message: GateMessage): GateMessage {
|
||||
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
|
||||
format: String(message.format ?? payload?.format ?? ''),
|
||||
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
|
||||
envelope_hash: String(message.envelope_hash ?? payload?.envelope_hash ?? ''),
|
||||
reply_to: String(message.reply_to ?? payload?.reply_to ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildGateAccessHeaders(gateId: string): Promise<Record<string, string> | undefined> {
|
||||
function describeGateCompatError(detail: string, gateId: string = ''): string {
|
||||
const normalized = String(detail || '').trim();
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (
|
||||
lowered.includes('transport tier insufficient') ||
|
||||
lowered.includes('warming up in the background')
|
||||
) {
|
||||
return 'The obfuscated lane is still warming up in the background. Stay in the room and posting should unlock shortly.';
|
||||
}
|
||||
if (normalized === 'gate_compat_fallback_consent_required') {
|
||||
return 'Local gate runtime is unavailable for this room.';
|
||||
}
|
||||
if (normalized.startsWith('gate_local_runtime_required:')) {
|
||||
const reason = normalized.slice('gate_local_runtime_required:'.length);
|
||||
return `${describeGateCompatReason(reason, gateId)} Use native desktop or resync local gate state.`;
|
||||
}
|
||||
if (normalized === 'gate_backend_plaintext_compat_required') {
|
||||
return 'Service-side gate send is disabled on this runtime. Use native desktop or an explicit compatibility override.';
|
||||
}
|
||||
if (normalized === 'gate_envelope_required') {
|
||||
return 'Local gate sealing is warming up. Your draft is still here.';
|
||||
}
|
||||
if (normalized === 'gate_envelope_encrypt_failed') {
|
||||
return 'Local gate sealing could not finish. Your draft is still here.';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function describeGateCompatConsentPrompt(action: string): string {
|
||||
switch (String(action || '')) {
|
||||
case 'decrypt':
|
||||
return 'Use compatibility mode for this room to read messages on this device.';
|
||||
case 'compose':
|
||||
case 'post':
|
||||
return 'Use compatibility mode for this room to send messages on this device.';
|
||||
default:
|
||||
return 'Use compatibility mode for this room on this device.';
|
||||
}
|
||||
}
|
||||
|
||||
function describeGateCompatReason(reason: string, gateId: string): string {
|
||||
const normalizedGate = String(gateId || '').trim().toLowerCase();
|
||||
if (!normalizedGate) return undefined;
|
||||
const cached = gateAccessHeaderCache.get(normalizedGate);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.headers;
|
||||
const detail = String(reason || '').trim().toLowerCase();
|
||||
if (!detail || detail === 'browser_local_gate_crypto_unavailable') {
|
||||
return 'Local gate crypto failed on this device.';
|
||||
}
|
||||
try {
|
||||
const proof = await controlPlaneJson<{ node_id?: string; ts?: number; proof?: string }>(
|
||||
'/api/wormhole/gate/proof',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gate_id: normalizedGate }),
|
||||
},
|
||||
);
|
||||
const nodeId = String(proof.node_id || '').trim();
|
||||
const gateProof = String(proof.proof || '').trim();
|
||||
const gateTs = String(proof.ts || '').trim();
|
||||
if (!nodeId || !gateProof || !gateTs) return undefined;
|
||||
const headers = {
|
||||
'X-Wormhole-Node-Id': nodeId,
|
||||
'X-Wormhole-Gate-Proof': gateProof,
|
||||
'X-Wormhole-Gate-Ts': gateTs,
|
||||
};
|
||||
gateAccessHeaderCache.set(normalizedGate, {
|
||||
headers,
|
||||
expiresAt: Date.now() + GATE_ACCESS_PROOF_TTL_MS,
|
||||
});
|
||||
return headers;
|
||||
} catch {
|
||||
return undefined;
|
||||
if (detail === 'browser_gate_worker_unavailable') {
|
||||
return 'This runtime cannot use the local gate worker.';
|
||||
}
|
||||
if (detail.startsWith('browser_gate_state_resync_required:')) {
|
||||
return normalizedGate
|
||||
? `Local ${normalizedGate} state needs a resync on this device.`
|
||||
: 'Local gate state needs a resync on this device.';
|
||||
}
|
||||
if (
|
||||
detail.startsWith('browser_gate_state_mapping_missing_group:') ||
|
||||
detail === 'browser_gate_state_active_member_missing'
|
||||
) {
|
||||
return 'Local gate state is incomplete on this device.';
|
||||
}
|
||||
if (detail === 'worker_gate_wrap_key_missing') {
|
||||
return 'Secure local gate storage is unavailable in this browser.';
|
||||
}
|
||||
if (detail === 'gate_mls_decrypt_failed') {
|
||||
return 'Local gate decrypt failed on this device.';
|
||||
}
|
||||
return 'Local gate crypto failed on this device.';
|
||||
}
|
||||
|
||||
interface GateCompatConsentPromptState {
|
||||
gateId: string;
|
||||
action: 'compose' | 'post' | 'decrypt';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export default function GateView({
|
||||
@@ -224,9 +284,17 @@ export default function GateView({
|
||||
onNavigateGate,
|
||||
onOpenLiveGate: _onOpenLiveGate,
|
||||
availableGates,
|
||||
onOpenShutdownPetition,
|
||||
}: GateViewProps) {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [messages, setMessages] = useState<GateMessage[]>([]);
|
||||
// Self-authored plaintext, keyed by real event_id returned from the POST.
|
||||
// This lives in React state ONLY — pure RAM, dies with the tab, never
|
||||
// written to disk or sessionStorage. It exists so a refresh that replaces
|
||||
// the messages array with ciphertext from the server doesn't wipe the
|
||||
// author's view of what they just said. MLS's forward-secrecy property
|
||||
// (sender can't re-decrypt own output) is preserved on the wire / on disk.
|
||||
const [selfAuthoredByEventId, setSelfAuthoredByEventId] = useState<Record<string, string>>({});
|
||||
const [composer, setComposer] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [roomError, setRoomError] = useState('');
|
||||
@@ -236,45 +304,116 @@ export default function GateView({
|
||||
const [reps, setReps] = useState<Record<string, number>>({});
|
||||
const [voteNotice, setVoteNotice] = useState('');
|
||||
const [votedOn, setVotedOn] = useState<Record<string, 1 | -1>>({});
|
||||
const [compatActive, setCompatActive] = useState(false);
|
||||
const [compatConsentPrompt, setCompatConsentPrompt] = useState<GateCompatConsentPromptState | null>(null);
|
||||
const [streamStatus, setStreamStatus] = useState(() => getGateSessionStreamStatus());
|
||||
const [streamStatusHydrated, setStreamStatusHydrated] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const waitAbortRef = useRef<AbortController | null>(null);
|
||||
const gateCursorRef = useRef(0);
|
||||
const repsRef = useRef<Record<string, number>>({});
|
||||
const streamEnabledForGateRef = useRef(false);
|
||||
|
||||
const gateId = useMemo(() => String(gateName || '').trim().toLowerCase(), [gateName]);
|
||||
const introMessage =
|
||||
GATE_INTROS[gateId] || 'Welcome to this gate. Be civil. The Shadowbroker is watching.';
|
||||
|
||||
useEffect(() => {
|
||||
setCompatActive(hasGateCompatFallbackApproval(gateId));
|
||||
setCompatConsentPrompt(null);
|
||||
gateCursorRef.current = 0;
|
||||
}, [gateId]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeGateSessionStreamStatus((nextStatus) => {
|
||||
setStreamStatus(nextStatus);
|
||||
setStreamStatusHydrated(true);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gateId || !status?.has_local_access) {
|
||||
return;
|
||||
}
|
||||
return retainGateSessionStreamGate(gateId);
|
||||
}, [gateId, status?.has_local_access]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gateId || !status?.has_local_access) {
|
||||
return;
|
||||
}
|
||||
void prepareWormholeInteractiveLane({
|
||||
minimumTransportTier: 'private_control_only',
|
||||
}).catch(() => undefined);
|
||||
}, [gateId, status?.has_local_access]);
|
||||
|
||||
const streamEnabledForGate =
|
||||
Boolean(gateId) &&
|
||||
streamStatus.phase === 'open' &&
|
||||
streamStatus.subscriptions.includes(gateId);
|
||||
const streamPreferredForGate =
|
||||
Boolean(gateId) &&
|
||||
(streamStatus.phase === 'connecting' || streamStatus.phase === 'open') &&
|
||||
streamStatus.subscriptions.includes(gateId);
|
||||
|
||||
useEffect(() => {
|
||||
streamEnabledForGateRef.current = streamPreferredForGate;
|
||||
}, [streamPreferredForGate]);
|
||||
|
||||
const searchMatch = searchInput.startsWith('g/')
|
||||
? availableGates.find((g) => g.startsWith(searchInput.slice(2).toLowerCase()))
|
||||
: null;
|
||||
|
||||
const voteScopeKey = useCallback((targetId: string) => `${gateId}::${String(targetId || '').trim()}`, [gateId]);
|
||||
|
||||
const hydrateMessages = useCallback(async (rawMessages: GateMessage[]): Promise<GateMessage[]> => {
|
||||
const hydrateMessages = useCallback(async (
|
||||
rawMessages: GateMessage[],
|
||||
): Promise<{ messages: GateMessage[]; compatDecryptBlocked: boolean; roomError?: string }> => {
|
||||
const baseMessages = (Array.isArray(rawMessages) ? rawMessages : []).map(normalizeGateMessage);
|
||||
const encrypted = baseMessages
|
||||
.map((message, index) => ({ message, index }))
|
||||
.filter(({ message }) => isEncryptedGateEnvelope(message));
|
||||
|
||||
if (!encrypted.length) {
|
||||
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
|
||||
return {
|
||||
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
|
||||
compatDecryptBlocked: false,
|
||||
roomError: '',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const batch = await decryptWormholeGateMessages(
|
||||
encrypted.map(({ message }) => ({
|
||||
gate_id: String(message.gate || gateId),
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
})),
|
||||
encrypted.map(({ message }) => {
|
||||
const gateEnvelope = String(message.gate_envelope || '');
|
||||
return {
|
||||
gate_id: String(message.gate || gateId),
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: gateEnvelope,
|
||||
envelope_hash: String(message.envelope_hash || ''),
|
||||
// If a gate_envelope is present, go straight to the backend
|
||||
// envelope-fast-path by signaling recovery_envelope=true.
|
||||
// This skips browser-side MLS (which has empty state across
|
||||
// fresh anon sessions) and uses the durable AES-GCM envelope
|
||||
// keyed under gate_secret — which EVERY gate member can
|
||||
// decrypt as long as they hold the current gate_secret.
|
||||
recovery_envelope: gateEnvelope.length > 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = Array.isArray(batch.results) ? batch.results : [];
|
||||
const nextMessages = [...baseMessages];
|
||||
encrypted.forEach(({ index, message }, resultIndex) => {
|
||||
const decrypted = results[resultIndex];
|
||||
const decryptedReplyTo = decrypted?.ok ? String(decrypted.reply_to || '').trim() : '';
|
||||
nextMessages[index] = {
|
||||
...message,
|
||||
decrypted_message: decrypted?.ok
|
||||
@@ -285,43 +424,45 @@ export default function GateView({
|
||||
: String(decrypted.plaintext || ''))
|
||||
: '',
|
||||
epoch: decrypted?.ok ? Number(decrypted.epoch || message.epoch || 0) : message.epoch,
|
||||
reply_to: decryptedReplyTo || String(message.reply_to || ''),
|
||||
};
|
||||
});
|
||||
return nextMessages;
|
||||
} catch {
|
||||
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
|
||||
return {
|
||||
messages: nextMessages,
|
||||
compatDecryptBlocked: false,
|
||||
roomError: '',
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : '';
|
||||
if (
|
||||
detail === 'gate_compat_fallback_consent_required' ||
|
||||
detail.startsWith('gate_local_runtime_required:')
|
||||
) {
|
||||
return {
|
||||
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
|
||||
compatDecryptBlocked: false,
|
||||
roomError: describeGateCompatError(detail, gateId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
|
||||
compatDecryptBlocked: false,
|
||||
roomError: '',
|
||||
};
|
||||
}
|
||||
}, [gateId]);
|
||||
|
||||
const refreshGate = useCallback(async () => {
|
||||
if (!gateId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextStatus = await fetchWormholeGateKeyStatus(gateId);
|
||||
setStatus(nextStatus);
|
||||
if (!nextStatus?.ok || !nextStatus.has_local_access) {
|
||||
setMessages([]);
|
||||
setRoomError(String(nextStatus?.detail || 'Gate access still syncing'));
|
||||
return;
|
||||
}
|
||||
const headers = await buildGateAccessHeaders(gateId);
|
||||
if (!headers) {
|
||||
setMessages([]);
|
||||
setRoomError('Gate proof unavailable');
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ limit: '40', gate: gateId });
|
||||
const res = await fetch(`${API_BASE}/api/mesh/infonet/messages?${params}`, { headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setMessages([]);
|
||||
setRoomError(String(data?.detail || 'Failed to load gate room'));
|
||||
return;
|
||||
}
|
||||
const hydrated = await hydrateMessages(Array.isArray(data.messages) ? data.messages : []);
|
||||
const chronological = [...hydrated].reverse();
|
||||
const applyGateMessages = useCallback(
|
||||
async (rawMessages: GateMessage[]) => {
|
||||
const normalizedMessages = Array.isArray(rawMessages) ? rawMessages : [];
|
||||
const hydrated = await hydrateMessages(normalizedMessages);
|
||||
const chronological = [...hydrated.messages].reverse();
|
||||
setMessages(chronological);
|
||||
setRoomError('');
|
||||
if (hydrated.roomError) {
|
||||
setRoomError(hydrated.roomError);
|
||||
} else if (!hydrated.compatDecryptBlocked) {
|
||||
setRoomError('');
|
||||
}
|
||||
|
||||
const uniqueEventIds = Array.from(
|
||||
new Set(
|
||||
@@ -332,14 +473,20 @@ export default function GateView({
|
||||
);
|
||||
if (uniqueEventIds.length > 0) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
for (const eid of uniqueEventIds) params.append('node_id', eid);
|
||||
const repRes = await fetch(`${API_BASE}/api/mesh/reputation/batch?${params}`);
|
||||
if (repRes.ok) {
|
||||
const repData = await repRes.json();
|
||||
const freshReps: Record<string, number> = {};
|
||||
if (repData.reputations && typeof repData.reputations === 'object') {
|
||||
for (const [k, v] of Object.entries(repData.reputations)) {
|
||||
const uncachedEventIds = uniqueEventIds.filter(
|
||||
(eventId) => !Object.prototype.hasOwnProperty.call(repsRef.current, eventId),
|
||||
);
|
||||
if (uncachedEventIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
for (const eid of uncachedEventIds) params.append('node_id', eid);
|
||||
const repRes = await fetch(`${API_BASE}/api/mesh/reputation/batch?${params}`);
|
||||
if (repRes.ok) {
|
||||
const repData = await repRes.json();
|
||||
const freshReps: Record<string, number> = {};
|
||||
if (repData.reputations && typeof repData.reputations === 'object') {
|
||||
for (const [k, v] of Object.entries(repData.reputations)) {
|
||||
freshReps[k] = Number(v || 0);
|
||||
}
|
||||
}
|
||||
@@ -351,32 +498,246 @@ export default function GateView({
|
||||
/* ignore batch rep fetch failure */
|
||||
}
|
||||
}
|
||||
},
|
||||
[hydrateMessages],
|
||||
);
|
||||
|
||||
const refreshGate = useCallback(async (options: { force?: boolean } = {}): Promise<boolean> => {
|
||||
if (!gateId) return false;
|
||||
setLoading(true);
|
||||
try {
|
||||
const streamOwned = streamEnabledForGateRef.current;
|
||||
const nextStatus = await fetchWormholeGateKeyStatus(gateId, {
|
||||
force: options.force,
|
||||
mode: streamOwned ? 'session_stream' : 'active_room',
|
||||
});
|
||||
setStatus(nextStatus);
|
||||
if (!nextStatus?.ok || !nextStatus.has_local_access) {
|
||||
gateCursorRef.current = 0;
|
||||
setMessages([]);
|
||||
setRoomError(String(nextStatus?.detail || 'Gate access still syncing'));
|
||||
return false;
|
||||
}
|
||||
if (options.force || !streamOwned || !status?.has_local_access) {
|
||||
await syncBrowserWormholeGateState(gateId).catch(() => false);
|
||||
}
|
||||
const snapshot = await fetchGateMessageSnapshotState(gateId, ACTIVE_GATE_ROOM_MESSAGE_LIMIT, {
|
||||
force: options.force,
|
||||
proofMode: streamOwned ? 'session_stream' : 'default',
|
||||
});
|
||||
gateCursorRef.current = snapshot.cursor;
|
||||
await applyGateMessages(snapshot.messages as GateMessage[]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [gateId, hydrateMessages]);
|
||||
}, [applyGateMessages, gateId, status?.has_local_access]);
|
||||
|
||||
// SSE: instant delivery when new gate events arrive
|
||||
const handleSSEEvent = useCallback(
|
||||
(eventGateId: string) => {
|
||||
if (eventGateId === gateId) void refreshGate();
|
||||
},
|
||||
[gateId, refreshGate],
|
||||
);
|
||||
useGateSSE(handleSSEEvent);
|
||||
|
||||
// Fallback poll (30s) in case SSE disconnects
|
||||
useEffect(() => {
|
||||
void refreshGate();
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshGate();
|
||||
}, 30_000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
if (!gateId || !status?.has_local_access || !streamEnabledForGate) {
|
||||
return;
|
||||
}
|
||||
return subscribeGateSessionStreamEvents((event) => {
|
||||
if (event.event !== 'gate_update' || !event.data || typeof event.data !== 'object') {
|
||||
return;
|
||||
}
|
||||
const updates = Array.isArray((event.data as { updates?: unknown }).updates)
|
||||
? ((event.data as { updates?: Array<{ gate_id?: string; cursor?: number }> }).updates || [])
|
||||
: [];
|
||||
const matching = updates.find(
|
||||
(update) => String(update?.gate_id || '').trim().toLowerCase() === gateId,
|
||||
);
|
||||
if (!matching) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const snapshot = await fetchGateMessageSnapshotState(
|
||||
gateId,
|
||||
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
|
||||
{ force: true, proofMode: 'session_stream' },
|
||||
);
|
||||
gateCursorRef.current = snapshot.cursor;
|
||||
await applyGateMessages(snapshot.messages as GateMessage[]);
|
||||
} catch {
|
||||
await refreshGate({ force: true });
|
||||
}
|
||||
})();
|
||||
});
|
||||
}, [applyGateMessages, gateId, refreshGate, status?.has_local_access, streamEnabledForGate]);
|
||||
|
||||
// Active gate rooms now wait for server-side change instead of issuing a fresh fetch on every cycle.
|
||||
useEffect(() => {
|
||||
if (!streamStatusHydrated) {
|
||||
return;
|
||||
}
|
||||
const isLiveStreamPreferredForGate = () => {
|
||||
const liveStreamStatus = getGateSessionStreamStatus();
|
||||
return (
|
||||
Boolean(gateId) &&
|
||||
(liveStreamStatus.phase === 'connecting' || liveStreamStatus.phase === 'open') &&
|
||||
liveStreamStatus.subscriptions.includes(gateId)
|
||||
);
|
||||
};
|
||||
}, [refreshGate]);
|
||||
const liveStreamPreferred = streamPreferredForGate || isLiveStreamPreferredForGate();
|
||||
streamEnabledForGateRef.current = liveStreamPreferred;
|
||||
let cancelled = false;
|
||||
const clearRetry = () => {
|
||||
if (pollTimerRef.current) {
|
||||
window.clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRetry = () => {
|
||||
if (cancelled || streamEnabledForGateRef.current) return;
|
||||
clearRetry();
|
||||
pollTimerRef.current = window.setTimeout(() => {
|
||||
pollTimerRef.current = null;
|
||||
void waitForNextChange();
|
||||
}, nextGateMessagesPollDelayMs());
|
||||
};
|
||||
|
||||
const startWaitIfNeeded = () => {
|
||||
queueMicrotask(() => {
|
||||
streamEnabledForGateRef.current =
|
||||
streamPreferredForGate || isLiveStreamPreferredForGate();
|
||||
if (!cancelled && !streamEnabledForGateRef.current) {
|
||||
void waitForNextChange();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const waitForNextChange = async () => {
|
||||
streamEnabledForGateRef.current =
|
||||
streamPreferredForGate || isLiveStreamPreferredForGate();
|
||||
if (cancelled || !gateId || streamEnabledForGateRef.current) return;
|
||||
const controller = new AbortController();
|
||||
waitAbortRef.current = controller;
|
||||
try {
|
||||
const snapshot = await waitForGateMessageSnapshot(
|
||||
gateId,
|
||||
gateCursorRef.current,
|
||||
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
|
||||
{
|
||||
timeoutMs: nextGateMessagesWaitTimeoutMs(),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
waitAbortRef.current = null;
|
||||
if (cancelled) return;
|
||||
gateCursorRef.current = snapshot.cursor;
|
||||
if (snapshot.changed) {
|
||||
await applyGateMessages(snapshot.messages as GateMessage[]);
|
||||
void waitForNextChange();
|
||||
return;
|
||||
}
|
||||
clearRetry();
|
||||
pollTimerRef.current = window.setTimeout(() => {
|
||||
pollTimerRef.current = null;
|
||||
void waitForNextChange();
|
||||
}, nextGateMessagesWaitRearmDelayMs());
|
||||
} catch (error) {
|
||||
waitAbortRef.current = null;
|
||||
if (cancelled || controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const ready = await refreshGate({ force: true });
|
||||
if (!ready) {
|
||||
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
startWaitIfNeeded();
|
||||
}
|
||||
};
|
||||
|
||||
if (liveStreamPreferred) {
|
||||
void refreshGate();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearRetry();
|
||||
if (waitAbortRef.current) {
|
||||
waitAbortRef.current.abort();
|
||||
waitAbortRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void refreshGate().then((ready) => {
|
||||
streamEnabledForGateRef.current =
|
||||
streamPreferredForGate || isLiveStreamPreferredForGate();
|
||||
if (!cancelled && ready && !streamEnabledForGateRef.current) {
|
||||
startWaitIfNeeded();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearRetry();
|
||||
if (waitAbortRef.current) {
|
||||
waitAbortRef.current.abort();
|
||||
waitAbortRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [applyGateMessages, gateId, refreshGate, streamPreferredForGate, streamStatusHydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
setCompatConsentPrompt(null);
|
||||
}, [gateId]);
|
||||
|
||||
useEffect(() => {
|
||||
repsRef.current = reps;
|
||||
}, [reps]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCompatFallback = (event: Event) => {
|
||||
const detail =
|
||||
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
|
||||
? (event.detail as { gateId?: string; action?: string })
|
||||
: {};
|
||||
const eventGateId = String(detail.gateId || '').trim().toLowerCase();
|
||||
if (!eventGateId || eventGateId !== gateId) {
|
||||
return;
|
||||
}
|
||||
setCompatActive(true);
|
||||
};
|
||||
window.addEventListener('sb:gate-compat-fallback', handleCompatFallback as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('sb:gate-compat-fallback', handleCompatFallback as EventListener);
|
||||
};
|
||||
}, [gateId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCompatConsentRequired = (event: Event) => {
|
||||
const detail =
|
||||
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
|
||||
? (event.detail as GateCompatConsentPromptState)
|
||||
: null;
|
||||
const eventGateId = String(detail?.gateId || '').trim().toLowerCase();
|
||||
if (!eventGateId || eventGateId !== gateId || !detail) {
|
||||
return;
|
||||
}
|
||||
setCompatConsentPrompt({
|
||||
gateId: eventGateId,
|
||||
action: detail.action,
|
||||
reason: String(detail.reason || ''),
|
||||
});
|
||||
};
|
||||
window.addEventListener(
|
||||
'sb:gate-compat-consent-required',
|
||||
handleCompatConsentRequired as EventListener,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'sb:gate-compat-consent-required',
|
||||
handleCompatConsentRequired as EventListener,
|
||||
);
|
||||
};
|
||||
}, [gateId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
@@ -405,27 +766,31 @@ export default function GateView({
|
||||
setBusy(true);
|
||||
setRoomError('');
|
||||
try {
|
||||
await controlPlaneJson<{ ok: boolean; detail?: string }>('/api/wormhole/gate/message/post', {
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
plaintext: msg,
|
||||
reply_to: replyContext?.eventId || '',
|
||||
}),
|
||||
});
|
||||
const gatePost = await postWormholeGateMessage(gateId, msg, replyContext?.eventId || '').catch((error) => ({
|
||||
ok: false,
|
||||
detail: error instanceof Error ? error.message : 'Gate post failed',
|
||||
}));
|
||||
if (gatePost?.ok === false) {
|
||||
throw new Error(describeGateCompatError(String(gatePost.detail || 'Gate post failed'), gateId));
|
||||
}
|
||||
setComposer('');
|
||||
setReplyContext(null);
|
||||
// Optimistic: append a placeholder message so the user sees it immediately,
|
||||
// then let the next poll cycle (8s) hydrate it with the real encrypted copy.
|
||||
// Capture the server-assigned event_id and remember the plaintext we
|
||||
// just authored, keyed by that event_id. The refresh will bring back
|
||||
// the same event as ciphertext; during render we paint over its
|
||||
// decrypted_message with what we typed. Pure React state — when the
|
||||
// tab closes, this map vanishes.
|
||||
const realEventId = String((gatePost as { event_id?: string })?.event_id || '');
|
||||
if (realEventId) {
|
||||
setSelfAuthoredByEventId((prev) => ({ ...prev, [realEventId]: msg }));
|
||||
}
|
||||
// Optimistic placeholder so the post appears instantly even before
|
||||
// the next refresh round-trip completes. Uses the real event_id when
|
||||
// available so the refresh merges cleanly rather than duplicating.
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
event_id: `_pending_${Date.now()}`,
|
||||
event_id: realEventId || `_pending_${Date.now()}`,
|
||||
message: msg,
|
||||
decrypted_message: msg,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
@@ -435,8 +800,6 @@ export default function GateView({
|
||||
ephemeral: true,
|
||||
} as GateMessage,
|
||||
]);
|
||||
// Non-blocking background refresh to pick up the real message
|
||||
void refreshGate();
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Gate post failed';
|
||||
// Suppress technical sequence/replay errors — just show a clean retry hint
|
||||
@@ -448,7 +811,21 @@ export default function GateView({
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, composer, gateId, persona, refreshGate, replyContext, status?.has_local_access]);
|
||||
}, [busy, composer, gateId, persona, replyContext, status?.has_local_access]);
|
||||
|
||||
const approveCompatFallback = useCallback(() => {
|
||||
if (!compatConsentPrompt?.gateId) return;
|
||||
approveGateCompatFallback(compatConsentPrompt.gateId);
|
||||
const action = compatConsentPrompt.action;
|
||||
setCompatActive(true);
|
||||
setCompatConsentPrompt(null);
|
||||
setRoomError('');
|
||||
if (action === 'decrypt') {
|
||||
void refreshGate({ force: true });
|
||||
return;
|
||||
}
|
||||
void handleSend();
|
||||
}, [compatConsentPrompt, handleSend, refreshGate]);
|
||||
|
||||
const handleVote = useCallback(async (eventId: string, vote: 1 | -1) => {
|
||||
if (!eventId || !gateId || votedOn[voteScopeKey(eventId)] === vote) return;
|
||||
@@ -503,19 +880,45 @@ export default function GateView({
|
||||
}
|
||||
}, [gateId, voteScopeKey, votedOn]);
|
||||
|
||||
const threadedMessages = useMemo(() => buildThreadedList(messages), [messages]);
|
||||
// Overlay self-authored plaintexts onto the refreshed message list.
|
||||
// Lives only in this component's React state; a tab close wipes it.
|
||||
const messagesWithSelfOverlay = useMemo(
|
||||
() =>
|
||||
messages.map((m) => {
|
||||
const eid = String(m.event_id || '');
|
||||
const selfText = eid ? selfAuthoredByEventId[eid] : '';
|
||||
if (!selfText) return m;
|
||||
return { ...m, decrypted_message: selfText };
|
||||
}),
|
||||
[messages, selfAuthoredByEventId],
|
||||
);
|
||||
const threadedMessages = useMemo(
|
||||
() => buildThreadedList(messagesWithSelfOverlay),
|
||||
[messagesWithSelfOverlay],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
{onOpenShutdownPetition && (
|
||||
<button
|
||||
onClick={() => onOpenShutdownPetition(gateName)}
|
||||
title="Open gate shutdown lifecycle (suspend / shutdown / appeal)"
|
||||
className="flex items-center text-amber-500 hover:text-amber-400 transition-all uppercase text-xs tracking-widest border border-amber-900/50 px-3 py-1 bg-amber-900/10 hover:bg-amber-900/30 hover:border-amber-500/50"
|
||||
>
|
||||
SHUTDOWN STATUS
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
LOGGED IN AS:{' '}
|
||||
<span
|
||||
@@ -530,11 +933,18 @@ export default function GateView({
|
||||
|
||||
<div className="flex items-center justify-between gap-4 mt-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest">g/{gateId}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest">g/{gateId}</h1>
|
||||
{compatActive ? (
|
||||
<span className="border border-amber-500/40 bg-amber-950/20 px-2 py-0.5 text-[10px] font-mono tracking-[0.2em] text-amber-200">
|
||||
COMPAT
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mt-1">Fixed obfuscated gate. Creation is disabled for this testnet.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void refreshGate()}
|
||||
onClick={() => void refreshGate({ force: true })}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 border border-cyan-500/30 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-900/30 transition-colors text-sm uppercase tracking-[0.22em]"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
@@ -593,11 +1003,31 @@ export default function GateView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{roomError ? (
|
||||
{roomError && !compatConsentPrompt ? (
|
||||
<div className="mb-3 shrink-0 border border-red-900/30 bg-red-950/10 px-3 py-2 text-[11px] text-red-300">
|
||||
{roomError}
|
||||
</div>
|
||||
) : null}
|
||||
{compatConsentPrompt ? (
|
||||
<div className="mb-3 shrink-0 border border-amber-500/30 bg-amber-950/15 px-3 py-2 text-[11px] text-amber-100/90">
|
||||
<div className="text-[12px] font-mono tracking-[0.2em] text-amber-300">COMPAT MODE</div>
|
||||
<div className="mt-1 leading-[1.7]">
|
||||
{describeGateCompatConsentPrompt(compatConsentPrompt.action)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-amber-200/70">
|
||||
{describeGateCompatReason(compatConsentPrompt.reason, compatConsentPrompt.gateId)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={approveCompatFallback}
|
||||
className="px-3 py-1.5 border border-amber-500/40 bg-amber-950/20 text-[11px] font-mono tracking-[0.18em] text-amber-100 hover:bg-amber-900/30 transition-colors"
|
||||
>
|
||||
ENABLE FOR ROOM
|
||||
</button>
|
||||
<span className="text-[11px] text-amber-200/70">Weaker privacy on this device.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{voteNotice ? (
|
||||
<div className="mb-2 shrink-0 border border-yellow-800/30 bg-yellow-950/10 px-3 py-1.5 text-sm text-yellow-400/80 font-mono">
|
||||
{voteNotice}
|
||||
@@ -644,10 +1074,14 @@ export default function GateView({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-mono">
|
||||
<span className="text-green-400" title={String(message.public_key || message.node_id || '')}>
|
||||
@{String(message.node_id || '').replace(/^!sb_/, '').slice(0, 8)
|
||||
|| String(message.public_key || '').slice(0, 8)
|
||||
|| 'unknown'}
|
||||
<span className="text-green-400">
|
||||
@{String(
|
||||
(message as unknown as { sender_handle?: string }).sender_handle
|
||||
|| ((message as unknown as { payload?: { sender_handle?: string } }).payload?.sender_handle)
|
||||
|| String(message.node_id || '').replace(/^!sb_/, '').slice(0, 8)
|
||||
|| String(message.public_key || '').slice(0, 8)
|
||||
|| 'anon_????',
|
||||
)}
|
||||
</span>
|
||||
{isEncryptedGateEnvelope(message) ? (
|
||||
<span
|
||||
@@ -657,7 +1091,7 @@ export default function GateView({
|
||||
: 'text-amber-300 border-amber-700/60'
|
||||
}`}
|
||||
>
|
||||
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'KEY LOCKED'}
|
||||
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'SEALED'}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[var(--text-muted)] text-[13px]">{timeAgo(message.timestamp)}</span>
|
||||
@@ -742,7 +1176,12 @@ export default function GateView({
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={composer}
|
||||
onChange={(e) => setComposer(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setComposer(e.target.value);
|
||||
if (roomError) {
|
||||
setRoomError('');
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail } from 'lucide-react';
|
||||
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail, Brain, GitBranch, Cpu, KeyRound } from 'lucide-react';
|
||||
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
activateWormholeGatePersona,
|
||||
@@ -19,6 +19,13 @@ import WeatherWidget from './WeatherWidget';
|
||||
import TrendingPosts from './TrendingPosts';
|
||||
import HashchainEvents from './HashchainEvents';
|
||||
import NetworkStats from './NetworkStats';
|
||||
import AIQueryView from './AIQueryView';
|
||||
import PetitionsView from './PetitionsView';
|
||||
import UpgradeView from './UpgradeView';
|
||||
import ResolutionView from './ResolutionView';
|
||||
import GateShutdownView from './GateShutdownView';
|
||||
import BootstrapView from './BootstrapView';
|
||||
import FunctionKeyView from './FunctionKeyView';
|
||||
|
||||
|
||||
const ASCII_HEADER = `
|
||||
@@ -38,11 +45,8 @@ const ASCII_HEADER = `
|
||||
`;
|
||||
|
||||
const COMING_SOON_MODULES: Record<string, { title: string; desc: string; status: string }> = {
|
||||
BALLOT: {
|
||||
title: 'BALLOT — DEMOCRACY FOR ALL SOON',
|
||||
desc: 'Governance surfaces are not live in this testnet shell yet. When they arrive, they should reflect real community demand, clear rules, and verifiable participation instead of placeholder politics.',
|
||||
status: 'MODULE STATUS: HOLDING SCREEN ONLY — NO LIVE BALLOTS OR COUNTS',
|
||||
},
|
||||
// BALLOT entry removed 2026-04-28: the BALLOT command now navigates
|
||||
// to PetitionsView (live governance DSL + petition lifecycle).
|
||||
GIGS: {
|
||||
title: 'GIGS — NETWORK BOUNTIES',
|
||||
desc: 'Decentralized work contracts, intelligence bounties, and mesh task allocation. Accept jobs, deliver payloads, and earn credits through verified proof-of-work completion.',
|
||||
@@ -60,6 +64,19 @@ const GATES = [
|
||||
'ukraine-front', 'iran-front', 'world-news', 'prediction-markets',
|
||||
'finance', 'cryptography', 'cryptocurrencies', 'meet-chat', 'opsec-lab'
|
||||
];
|
||||
const GATE_LAUNCH_RETRY_DELAY_MS = 3000;
|
||||
const GATE_LAUNCH_RETRY_ATTEMPTS = 20;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function isGateLaneStartingError(detail: string): boolean {
|
||||
const lowered = String(detail || '').trim().toLowerCase();
|
||||
return lowered.includes('obfuscated lane is still starting');
|
||||
}
|
||||
|
||||
const SHELL_ANON_PERSONAS_KEY = 'sb_infonet_shell_anon_personas';
|
||||
|
||||
@@ -99,7 +116,11 @@ function allocateShellAnonPersona(): string {
|
||||
|
||||
const SECTIONS = [
|
||||
{ name: 'HELP', icon: <Terminal size={14} className="mr-2" /> },
|
||||
{ name: 'AI', icon: <Brain size={14} className="mr-2" /> },
|
||||
{ name: 'BALLOT', icon: <Vote size={14} className="mr-2" /> },
|
||||
{ name: 'UPGRADES', icon: <GitBranch size={14} className="mr-2" /> },
|
||||
{ name: 'BOOTSTRAP', icon: <Cpu size={14} className="mr-2" /> },
|
||||
{ name: 'F-KEYS', icon: <KeyRound size={14} className="mr-2" /> },
|
||||
{ name: 'GIGS', icon: <Briefcase size={14} className="mr-2" /> },
|
||||
{ name: 'MESH', icon: <Globe size={14} className="mr-2" /> },
|
||||
{ name: 'GATES', icon: <Key size={14} className="mr-2" /> },
|
||||
@@ -119,16 +140,26 @@ interface InfonetShellProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
|
||||
}
|
||||
|
||||
export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: InfonetShellProps) {
|
||||
export default function InfonetShell({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenLiveGate,
|
||||
onOpenDeadDrop,
|
||||
}: InfonetShellProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [history, setHistory] = useState<CommandHistory[]>([]);
|
||||
const [isBooting, setIsBooting] = useState(true);
|
||||
const [bootText, setBootText] = useState<string[]>([]);
|
||||
|
||||
// Navigation & State
|
||||
const [currentView, setCurrentView] = useState<'terminal' | 'gate' | 'market' | 'profile' | 'messages'>('terminal');
|
||||
type ViewName =
|
||||
| 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai'
|
||||
| 'petitions' | 'upgrades' | 'resolution' | 'gate-shutdown'
|
||||
| 'bootstrap' | 'function-keys';
|
||||
const [currentView, setCurrentView] = useState<ViewName>('terminal');
|
||||
const [activeGate, setActiveGate] = useState<string | null>(null);
|
||||
const [persona, setPersona] = useState<string | null>(null);
|
||||
const [activeGateMode, setActiveGateMode] = useState<'anonymous' | 'persona' | null>(null);
|
||||
@@ -137,10 +168,15 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
const [isCitizen] = useState(false);
|
||||
const [comingSoonModule, setComingSoonModule] = useState<string | null>(null);
|
||||
const [wormholePromptKey, setWormholePromptKey] = useState('');
|
||||
// Targets for parameterized economy views.
|
||||
const [resolutionMarketId, setResolutionMarketId] = useState<string | null>(null);
|
||||
const [shutdownGateId, setShutdownGateId] = useState<string | null>(null);
|
||||
const [bootstrapMarketId, setBootstrapMarketId] = useState<string | null>(null);
|
||||
|
||||
const endOfTerminalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const gateLaunchAttemptRef = useRef(0);
|
||||
|
||||
// Real mesh identity
|
||||
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
|
||||
@@ -167,6 +203,7 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
setInputMode('normal');
|
||||
setPendingGate(null);
|
||||
setInput('');
|
||||
gateLaunchAttemptRef.current += 1;
|
||||
setIsBooting(true);
|
||||
setBootText([]);
|
||||
|
||||
@@ -235,7 +272,7 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
endOfTerminalRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [history]);
|
||||
|
||||
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages', gate?: string) => {
|
||||
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai', gate?: string) => {
|
||||
if (view === 'gate' && gate) {
|
||||
if (onOpenLiveGate) {
|
||||
setPendingGate(gate);
|
||||
@@ -262,6 +299,58 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
setCurrentView(view);
|
||||
};
|
||||
|
||||
const openGateWhenReady = async (
|
||||
gateTarget: string,
|
||||
operation: () => Promise<void>,
|
||||
options: { commandLabel: string; waitingOutput: React.ReactNode; failurePrefix: string },
|
||||
) => {
|
||||
const launchId = ++gateLaunchAttemptRef.current;
|
||||
let waitingShown = false;
|
||||
for (let attempt = 0; attempt < GATE_LAUNCH_RETRY_ATTEMPTS; attempt += 1) {
|
||||
if (gateLaunchAttemptRef.current !== launchId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : options.failurePrefix;
|
||||
if (!isGateLaneStartingError(detail)) {
|
||||
if (gateLaunchAttemptRef.current !== launchId) {
|
||||
return;
|
||||
}
|
||||
setHistory(prev => [...prev, {
|
||||
command: options.commandLabel,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
return;
|
||||
}
|
||||
if (!waitingShown) {
|
||||
waitingShown = true;
|
||||
setHistory(prev => [...prev, {
|
||||
command: options.commandLabel,
|
||||
output: options.waitingOutput,
|
||||
}]);
|
||||
}
|
||||
if (attempt === GATE_LAUNCH_RETRY_ATTEMPTS - 1) {
|
||||
if (gateLaunchAttemptRef.current !== launchId) {
|
||||
return;
|
||||
}
|
||||
setHistory(prev => [...prev, {
|
||||
command: options.commandLabel,
|
||||
output: (
|
||||
<span className="text-red-400">
|
||||
ERR: The obfuscated lane is taking too long to come online. It is still warming up in the background.
|
||||
</span>
|
||||
),
|
||||
}]);
|
||||
return;
|
||||
}
|
||||
await sleep(GATE_LAUNCH_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommand = (cmd: string) => {
|
||||
const trimmedCmd = cmd.trim().toLowerCase();
|
||||
let output: React.ReactNode = '';
|
||||
@@ -293,18 +382,24 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
setPendingGate(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await enterWormholeGate(gateTarget, true);
|
||||
setActiveGateMode('anonymous');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'anonymous_gate_enter_failed';
|
||||
setHistory(prev => [...prev, {
|
||||
command: `gate ${gateTarget}`,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
}
|
||||
await openGateWhenReady(
|
||||
gateTarget,
|
||||
async () => {
|
||||
await enterWormholeGate(gateTarget, true);
|
||||
setActiveGateMode('anonymous');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
},
|
||||
{
|
||||
commandLabel: `gate ${gateTarget}`,
|
||||
waitingOutput: (
|
||||
<span className="text-cyan-400">
|
||||
Warming the obfuscated lane for g/{gateTarget}. The room will open automatically as soon as it is ready.
|
||||
</span>
|
||||
),
|
||||
failurePrefix: 'anonymous_gate_enter_failed',
|
||||
},
|
||||
);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
@@ -312,30 +407,36 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
setPendingGate(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const personas = await listWormholeGatePersonas(gateTarget);
|
||||
const existing = Array.isArray(personas?.personas)
|
||||
? personas.personas.find(
|
||||
(candidate) =>
|
||||
String(candidate?.label || '').trim().toLowerCase() === chosenPersona.toLowerCase(),
|
||||
)
|
||||
: null;
|
||||
const result = existing?.persona_id
|
||||
? await activateWormholeGatePersona(gateTarget, existing.persona_id)
|
||||
: await createWormholeGatePersona(gateTarget, chosenPersona);
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.detail || 'gate_face_create_failed');
|
||||
}
|
||||
setActiveGateMode('persona');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'gate_face_create_failed';
|
||||
setHistory(prev => [...prev, {
|
||||
command: `join ${gateTarget}`,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
}
|
||||
await openGateWhenReady(
|
||||
gateTarget,
|
||||
async () => {
|
||||
const personas = await listWormholeGatePersonas(gateTarget);
|
||||
const existing = Array.isArray(personas?.personas)
|
||||
? personas.personas.find(
|
||||
(candidate) =>
|
||||
String(candidate?.label || '').trim().toLowerCase() === chosenPersona.toLowerCase(),
|
||||
)
|
||||
: null;
|
||||
const result = existing?.persona_id
|
||||
? await activateWormholeGatePersona(gateTarget, existing.persona_id)
|
||||
: await createWormholeGatePersona(gateTarget, chosenPersona);
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.detail || 'gate_face_create_failed');
|
||||
}
|
||||
setActiveGateMode('persona');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
},
|
||||
{
|
||||
commandLabel: `join ${gateTarget}`,
|
||||
waitingOutput: (
|
||||
<span className="text-cyan-400">
|
||||
Warming the obfuscated lane for g/{gateTarget}. Your gate face will open automatically when the room is ready.
|
||||
</span>
|
||||
),
|
||||
failurePrefix: 'gate_face_create_failed',
|
||||
},
|
||||
);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
@@ -351,7 +452,12 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
<li><span className="text-gray-300 font-bold">radio</span> - Open SIGINT / radio surfaces</li>
|
||||
<li><span className="text-gray-300 font-bold">messages</span> - Open Secure Comms</li>
|
||||
<li><span className="text-gray-300 font-bold">profile</span> - View sovereign identity & ledger</li>
|
||||
<li><span className="text-gray-300 font-bold">ballot</span> - View democratic proposals</li>
|
||||
<li><span className="text-gray-300 font-bold">ballot / petitions / governance</span> - File / sign / vote on petitions (DSL executor)</li>
|
||||
<li><span className="text-gray-300 font-bold">upgrades</span> - Upgrade-hash governance + Heavy-Node readiness</li>
|
||||
<li><span className="text-gray-300 font-bold">resolution [market_id]</span> - Evidence + dispute view</li>
|
||||
<li><span className="text-gray-300 font-bold">shutdown [gate_id]</span> - Gate suspend / shutdown / appeal lifecycle</li>
|
||||
<li><span className="text-gray-300 font-bold">bootstrap</span> - Bootstrap-mode resolution + ramp milestones</li>
|
||||
<li><span className="text-gray-300 font-bold">fkeys / function-keys</span> - Anonymous citizenship proof design</li>
|
||||
<li><span className="text-gray-300 font-bold">gigs</span> - View network bounties & jobs</li>
|
||||
<li><span className="text-gray-300 font-bold">markets</span> - View prediction markets</li>
|
||||
<li><span className="text-gray-300 font-bold">exchange</span> - Decentralized crypto exchange</li>
|
||||
@@ -387,6 +493,9 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
} else {
|
||||
output = <span className="text-red-400">ERR: Gate '{target}' not found or access denied.</span>;
|
||||
}
|
||||
} else if (trimmedCmd === 'ai' || trimmedCmd === 'copilot' || trimmedCmd === 'openclaw') {
|
||||
handleNavigate('ai');
|
||||
return;
|
||||
} else if (trimmedCmd === 'markets') {
|
||||
handleNavigate('market');
|
||||
return;
|
||||
@@ -396,9 +505,35 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
} else if (trimmedCmd === 'profile') {
|
||||
handleNavigate('profile');
|
||||
return;
|
||||
} else if (trimmedCmd === 'ballot') {
|
||||
setComingSoonModule('BALLOT');
|
||||
} else if (trimmedCmd === 'ballot' || trimmedCmd === 'petitions' || trimmedCmd === 'governance') {
|
||||
setCurrentView('petitions');
|
||||
return;
|
||||
} else if (trimmedCmd === 'upgrades' || trimmedCmd === 'upgrade') {
|
||||
setCurrentView('upgrades');
|
||||
return;
|
||||
} else if (trimmedCmd === 'bootstrap') {
|
||||
setBootstrapMarketId(null);
|
||||
setCurrentView('bootstrap');
|
||||
return;
|
||||
} else if (trimmedCmd === 'function-keys' || trimmedCmd === 'fkeys') {
|
||||
setCurrentView('function-keys');
|
||||
return;
|
||||
} else if (trimmedCmd.startsWith('resolution ')) {
|
||||
const mid = trimmedCmd.slice('resolution '.length).trim();
|
||||
if (mid) {
|
||||
setResolutionMarketId(mid);
|
||||
setCurrentView('resolution');
|
||||
return;
|
||||
}
|
||||
output = <span className="text-red-400">Usage: resolution <market_id></span>;
|
||||
} else if (trimmedCmd.startsWith('shutdown ')) {
|
||||
const gid = trimmedCmd.slice('shutdown '.length).trim();
|
||||
if (gid) {
|
||||
setShutdownGateId(gid);
|
||||
setCurrentView('gate-shutdown');
|
||||
return;
|
||||
}
|
||||
output = <span className="text-red-400">Usage: shutdown <gate_id></span>;
|
||||
} else if (trimmedCmd === 'work' || trimmedCmd === 'gigs') {
|
||||
setComingSoonModule('GIGS');
|
||||
return;
|
||||
@@ -495,7 +630,11 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
{SECTIONS.map((section) => (
|
||||
<button
|
||||
key={section.name}
|
||||
onClick={() => handleCommand(section.name === 'PROFILE' ? 'profile' : section.name.toLowerCase())}
|
||||
onClick={() => handleCommand(
|
||||
section.name === 'PROFILE' ? 'profile' :
|
||||
section.name === 'F-KEYS' ? 'fkeys' :
|
||||
section.name.toLowerCase()
|
||||
)}
|
||||
className="flex items-center px-2 py-1 bg-cyan-900/10 border border-cyan-900/50 text-cyan-500 hover:bg-cyan-900/30 hover:text-cyan-400 hover:border-cyan-500/50 transition-all text-sm md:text-xs uppercase tracking-widest whitespace-nowrap"
|
||||
>
|
||||
{section.icon}
|
||||
@@ -593,6 +732,10 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
onBack={() => handleNavigate('terminal')}
|
||||
onNavigateGate={(gate) => handleNavigate('gate', gate)}
|
||||
onOpenLiveGate={onOpenLiveGate}
|
||||
onOpenShutdownPetition={(gate) => {
|
||||
setShutdownGateId(gate);
|
||||
setCurrentView('gate-shutdown');
|
||||
}}
|
||||
availableGates={GATES}
|
||||
/>
|
||||
)}
|
||||
@@ -612,7 +755,44 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
)}
|
||||
|
||||
{currentView === 'messages' && (
|
||||
<MessagesView onBack={() => handleNavigate('terminal')} />
|
||||
<MessagesView onBack={() => handleNavigate('terminal')} onOpenDeadDrop={onOpenDeadDrop} />
|
||||
)}
|
||||
|
||||
{currentView === 'ai' && (
|
||||
<AIQueryView onBack={() => handleNavigate('terminal')} />
|
||||
)}
|
||||
|
||||
{currentView === 'petitions' && (
|
||||
<PetitionsView onBack={() => setCurrentView('terminal')} />
|
||||
)}
|
||||
|
||||
{currentView === 'upgrades' && (
|
||||
<UpgradeView onBack={() => setCurrentView('terminal')} />
|
||||
)}
|
||||
|
||||
{currentView === 'resolution' && resolutionMarketId && (
|
||||
<ResolutionView
|
||||
marketId={resolutionMarketId}
|
||||
onBack={() => setCurrentView('terminal')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'gate-shutdown' && shutdownGateId && (
|
||||
<GateShutdownView
|
||||
gateId={shutdownGateId}
|
||||
onBack={() => setCurrentView('terminal')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'bootstrap' && (
|
||||
<BootstrapView
|
||||
marketId={bootstrapMarketId ?? undefined}
|
||||
onBack={() => setCurrentView('terminal')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'function-keys' && (
|
||||
<FunctionKeyView onBack={() => setCurrentView('terminal')} />
|
||||
)}
|
||||
|
||||
{/* Coming Soon Popup */}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper } from 'lucide-react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper, ExternalLink, Loader } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import type { DashboardData, StockTicker } from '@/types/dashboard';
|
||||
|
||||
function formatVolume(vol: number): string {
|
||||
function formatVolume(vol: number | null | undefined): string {
|
||||
if (!vol || vol <= 0) return '';
|
||||
if (vol >= 1_000_000) return `$${(vol / 1_000_000).toFixed(1)}M`;
|
||||
if (vol >= 1_000) return `$${(vol / 1_000).toFixed(0)}K`;
|
||||
@@ -32,33 +33,185 @@ const CATEGORY_CONFIG: Record<string, { color: string; icon: typeof Shield }> =
|
||||
CONFLICT: { color: 'text-red-400', icon: Crosshair },
|
||||
FINANCE: { color: 'text-emerald-400', icon: DollarSign },
|
||||
CRYPTO: { color: 'text-amber-400', icon: DollarSign },
|
||||
SPORTS: { color: 'text-orange-400', icon: Activity },
|
||||
NEWS: { color: 'text-cyan-400', icon: Newspaper },
|
||||
};
|
||||
|
||||
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'NEWS';
|
||||
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'SPORTS' | 'NEWS';
|
||||
|
||||
interface MarketViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type MarketSource = {
|
||||
name: string;
|
||||
pct: number;
|
||||
};
|
||||
|
||||
type MarketOutcome = {
|
||||
name: string;
|
||||
pct: number;
|
||||
};
|
||||
|
||||
type PredictionMarket = {
|
||||
title: string;
|
||||
category?: Category | string;
|
||||
consensus_pct?: number | null;
|
||||
polymarket_pct?: number | null;
|
||||
kalshi_pct?: number | null;
|
||||
volume?: number | null;
|
||||
volume_24h?: number | null;
|
||||
end_date?: string | null;
|
||||
description?: string | null;
|
||||
sources?: MarketSource[];
|
||||
slug?: string;
|
||||
kalshi_ticker?: string;
|
||||
outcomes?: MarketOutcome[];
|
||||
delta_pct?: number | null;
|
||||
consensus?: {
|
||||
total_picks: number;
|
||||
total_staked: number;
|
||||
};
|
||||
};
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'trending_markets' | 'stocks'>;
|
||||
const DATA_KEYS = ['trending_markets', 'stocks'] as const;
|
||||
|
||||
export default function MarketView({ onBack }: MarketViewProps) {
|
||||
const [category, setCategory] = useState<Category>('ALL');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<PredictionMarket[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [allMarkets, setAllMarkets] = useState<PredictionMarket[]>([]);
|
||||
const [marketTotals, setMarketTotals] = useState<Record<string, number>>({});
|
||||
const [marketHasMore, setMarketHasMore] = useState<Record<string, boolean>>({});
|
||||
const [searchHasMore, setSearchHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [allBrowseOffset, setAllBrowseOffset] = useState(0);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
const markets = data?.trending_markets || [];
|
||||
const stocks = data?.stocks;
|
||||
|
||||
const filteredMarkets = markets.filter(m => {
|
||||
const appendUniqueMarkets = useCallback((existing: PredictionMarket[], incoming: PredictionMarket[]) => {
|
||||
const seen = new Set(existing.map((m) => String(m.slug || m.kalshi_ticker || m.title).toLowerCase()));
|
||||
const next = [...existing];
|
||||
for (const market of incoming) {
|
||||
const key = String(market.slug || market.kalshi_ticker || market.title).toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
next.push(market);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
// Fetch all markets from the oracle endpoint on mount
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/mesh/oracle/markets`);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
const cats = d.categories || {};
|
||||
const all: PredictionMarket[] = [];
|
||||
for (const cat of Object.values(cats) as PredictionMarket[][]) {
|
||||
all.push(...cat);
|
||||
}
|
||||
if (mounted) {
|
||||
setAllMarkets(appendUniqueMarkets([], all));
|
||||
const totals = d.cat_totals || {};
|
||||
setMarketTotals({ ...totals, ALL: d.total_count || all.length });
|
||||
const more: Record<string, boolean> = {};
|
||||
for (const [cat, count] of Object.entries(totals)) {
|
||||
const loaded = Array.isArray(cats[cat]) ? cats[cat].length : 0;
|
||||
more[cat] = Number(count) > loaded;
|
||||
}
|
||||
more.ALL = Number(d.total_count || 0) > all.length;
|
||||
setMarketHasMore(more);
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [appendUniqueMarkets]);
|
||||
|
||||
// API search — hits Polymarket + Kalshi directly
|
||||
const searchMarkets = useCallback(async (query: string, offset = 0) => {
|
||||
if (query.length < 2) {
|
||||
setSearchResults([]);
|
||||
setSearchHasMore(false);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/mesh/oracle/search?q=${encodeURIComponent(query)}&limit=50&offset=${offset}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
const results = d.results || [];
|
||||
setSearchResults((prev) => (offset > 0 ? appendUniqueMarkets(prev, results) : results));
|
||||
setSearchHasMore(Boolean(d.has_more));
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setIsSearching(false);
|
||||
}, [appendUniqueMarkets]);
|
||||
|
||||
const loadMoreMarkets = useCallback(async () => {
|
||||
if (loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
if (searchInput.length >= 2) {
|
||||
await searchMarkets(searchInput, searchResults.length);
|
||||
return;
|
||||
}
|
||||
const loadedForCategory =
|
||||
category === 'ALL'
|
||||
? allBrowseOffset
|
||||
: allMarkets.filter((m) => m.category === category).length;
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/mesh/oracle/markets/more?category=${encodeURIComponent(category)}&offset=${loadedForCategory}&limit=50`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
const markets = d.markets || [];
|
||||
setAllMarkets((prev) => appendUniqueMarkets(prev, markets));
|
||||
if (category === 'ALL') {
|
||||
setAllBrowseOffset((prev) => prev + markets.length);
|
||||
}
|
||||
setMarketHasMore((prev) => ({ ...prev, [category]: Boolean(d.has_more) }));
|
||||
setMarketTotals((prev) => ({ ...prev, [category]: d.total ?? prev[category] ?? loadedForCategory }));
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [allBrowseOffset, allMarkets, appendUniqueMarkets, category, loadingMore, searchInput, searchMarkets, searchResults.length]);
|
||||
|
||||
const handleSearchInput = useCallback(
|
||||
(value: string) => {
|
||||
setSearchInput(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => searchMarkets(value), 400);
|
||||
},
|
||||
[searchMarkets],
|
||||
);
|
||||
|
||||
// Use search results when searching, otherwise show all markets
|
||||
const displayMarkets = searchInput.length >= 2 ? searchResults : allMarkets;
|
||||
const filteredMarkets = displayMarkets.filter(m => {
|
||||
const matchesCat = category === 'ALL' || m.category === category;
|
||||
const matchesSearch = !searchInput || m.title.toLowerCase().includes(searchInput.toLowerCase());
|
||||
return matchesCat && matchesSearch;
|
||||
return matchesCat;
|
||||
});
|
||||
|
||||
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'NEWS'];
|
||||
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'SPORTS', 'NEWS'];
|
||||
const currentTotal = searchInput.length >= 2
|
||||
? null
|
||||
: marketTotals[category] ?? filteredMarkets.length;
|
||||
const canLoadMore = searchInput.length >= 2 ? searchHasMore : Boolean(marketHasMore[category]);
|
||||
|
||||
// Build ticker from real stocks data
|
||||
const tickerItems: string[] = [];
|
||||
@@ -87,7 +240,10 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
<Activity className="mr-2 text-cyan-400 animate-pulse" />
|
||||
PREDICTION MARKETS
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Live Polymarket + Kalshi feeds. {markets.length} active markets tracked.</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Live Polymarket + Kalshi feeds. Search anything — all markets from both platforms.
|
||||
{' '}{allMarkets.length > 0 && `${allMarkets.length} cached markets.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
@@ -107,32 +263,45 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 font-mono">{filteredMarkets.length} RESULTS</span>
|
||||
<span className="text-sm text-gray-500 font-mono">
|
||||
{filteredMarkets.length}{currentTotal != null && currentTotal > filteredMarkets.length ? ` / ${currentTotal}` : ''} RESULTS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4 shrink-0">
|
||||
<div className="flex items-center border border-gray-800 bg-[#0a0a0a] p-2">
|
||||
<Search size={14} className="text-gray-600 mr-2" />
|
||||
{isSearching ? (
|
||||
<Loader size={14} className="text-cyan-500 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Search size={14} className="text-gray-600 mr-2" />
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search prediction markets..."
|
||||
onChange={(e) => handleSearchInput(e.target.value)}
|
||||
placeholder="Search ALL Polymarket + Kalshi markets (e.g. avalanche, bitcoin, trump, war)..."
|
||||
className="bg-transparent border-none outline-none text-white w-full text-sm placeholder-gray-700"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{searchInput.length >= 2 && (
|
||||
<div className="text-xs font-mono text-gray-600 mt-1 px-1">
|
||||
{isSearching
|
||||
? 'SEARCHING POLYMARKET + KALSHI APIs...'
|
||||
: `${searchResults.length} RESULTS FROM POLYMARKET + KALSHI`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Markets List */}
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-3 pb-4">
|
||||
{filteredMarkets.length > 0 ? filteredMarkets.map((market, i) => {
|
||||
const pct = market.consensus_pct ?? market.polymarket_pct ?? market.kalshi_pct ?? 0;
|
||||
const catConfig = CATEGORY_CONFIG[market.category] || { color: 'text-gray-400' };
|
||||
const categoryLabel = market.category ?? 'UNCATEGORIZED';
|
||||
const catConfig = CATEGORY_CONFIG[categoryLabel] || { color: 'text-gray-400' };
|
||||
const vol = formatVolume(market.volume);
|
||||
const vol24 = formatVolume(market.volume_24h);
|
||||
// Runtime-optional fields the backend may send but aren't in the strict TS type
|
||||
const raw = market as Record<string, unknown>;
|
||||
const endDate = formatEndDate(typeof raw.end_date === 'string' ? raw.end_date : null);
|
||||
const outcomes = market.outcomes && market.outcomes.length > 0 ? market.outcomes : null;
|
||||
@@ -145,7 +314,7 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
<div className="flex-1">
|
||||
<div className="text-gray-300 font-bold text-sm md:text-base leading-snug">{market.title}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-sm font-mono">
|
||||
<span className={`${catConfig.color} uppercase tracking-widest`}>{market.category}</span>
|
||||
<span className={`${catConfig.color} uppercase tracking-widest`}>{categoryLabel}</span>
|
||||
{vol && <span className="text-gray-500">VOL: {vol}</span>}
|
||||
{vol24 && <span className="text-gray-500">24H: {vol24}</span>}
|
||||
{endDate && <span className="text-gray-500">CLOSES: {endDate}</span>}
|
||||
@@ -187,7 +356,7 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source badges */}
|
||||
{/* Source badges + external links */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{market.sources?.map((s, si) => (
|
||||
@@ -205,6 +374,23 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
{consensus.total_staked > 0 ? ` · ${consensus.total_staked.toFixed(1)} REP` : ''}
|
||||
</span>
|
||||
)}
|
||||
{/* External links */}
|
||||
{market.slug && (
|
||||
<button
|
||||
onClick={() => window.open(`https://polymarket.com/event/${market.slug}`, '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 border border-purple-500/30 bg-purple-500/10 text-purple-400 hover:bg-purple-500/20 cursor-pointer"
|
||||
>
|
||||
<ExternalLink size={9} /> POLY
|
||||
</button>
|
||||
)}
|
||||
{market.kalshi_ticker && (
|
||||
<button
|
||||
onClick={() => window.open(`https://kalshi.com/markets/${market.kalshi_ticker}`, '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 cursor-pointer"
|
||||
>
|
||||
<ExternalLink size={9} /> KALSHI
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delta indicator */}
|
||||
@@ -233,11 +419,31 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
);
|
||||
}) : (
|
||||
<div className="text-center text-gray-600 py-8">
|
||||
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
|
||||
{isSearching ? (
|
||||
<p className="text-sm">Searching Polymarket + Kalshi...</p>
|
||||
) : (
|
||||
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canLoadMore && (
|
||||
<div className="shrink-0 flex justify-center pb-3">
|
||||
<button
|
||||
onClick={() => void loadMoreMarkets()}
|
||||
disabled={loadingMore || isSearching}
|
||||
className="px-4 py-2 text-xs uppercase tracking-widest border border-cyan-900/50 bg-cyan-900/10 text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-900/30 disabled:opacity-50"
|
||||
>
|
||||
{loadingMore || isSearching
|
||||
? 'LOADING MORE...'
|
||||
: searchInput.length >= 2
|
||||
? 'MORE SEARCH RESULTS'
|
||||
: `MORE ${category} MARKETS`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticker */}
|
||||
{tickerItems.length > 0 && (
|
||||
<div className="shrink-0 border-t border-gray-800 bg-gray-900/30 overflow-hidden py-2 mt-2">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,618 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, FileText, Vote, Shield, AlertCircle, CheckCircle2, Loader } from 'lucide-react';
|
||||
import {
|
||||
buildChallengeFilePayload,
|
||||
buildPetitionFilePayload,
|
||||
buildPetitionSignPayload,
|
||||
buildPetitionVotePayload,
|
||||
fetchPetitions,
|
||||
freshLocalId,
|
||||
previewPetitionPayload,
|
||||
signAndAppend,
|
||||
type PetitionPayload,
|
||||
type PetitionState,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface PetitionsViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, { color: string; label: string; icon: typeof Vote }> = {
|
||||
signatures: { color: 'text-cyan-400', label: 'COLLECTING SIGNATURES', icon: FileText },
|
||||
voting: { color: 'text-blue-400', label: 'VOTING', icon: Vote },
|
||||
challenge: { color: 'text-amber-400', label: 'CHALLENGE WINDOW', icon: Shield },
|
||||
passed: { color: 'text-green-400', label: 'PASSED', icon: CheckCircle2 },
|
||||
executed: { color: 'text-green-500', label: 'EXECUTED', icon: CheckCircle2 },
|
||||
failed_signatures: { color: 'text-red-400', label: 'FAILED — SIGNATURES', icon: AlertCircle },
|
||||
failed_vote: { color: 'text-red-400', label: 'FAILED — VOTE', icon: AlertCircle },
|
||||
voided_challenge: { color: 'text-red-500', label: 'VOIDED BY CHALLENGE', icon: AlertCircle },
|
||||
not_found: { color: 'text-gray-500', label: 'NOT FOUND', icon: AlertCircle },
|
||||
};
|
||||
|
||||
function formatRelative(ts: number, now: number): string {
|
||||
if (!ts) return '—';
|
||||
const delta = ts - now;
|
||||
const abs = Math.abs(delta);
|
||||
const days = Math.floor(abs / 86400);
|
||||
const hours = Math.floor((abs % 86400) / 3600);
|
||||
if (delta > 0) {
|
||||
if (days > 0) return `in ${days}d ${hours}h`;
|
||||
if (hours > 0) return `in ${hours}h`;
|
||||
return `in ${Math.floor(abs / 60)}m`;
|
||||
} else {
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
return `${Math.floor(abs / 60)}m ago`;
|
||||
}
|
||||
}
|
||||
|
||||
function PayloadSummary({ payload }: { payload: PetitionPayload | Record<string, unknown> }) {
|
||||
const t = (payload as { type?: string }).type;
|
||||
if (t === 'UPDATE_PARAM') {
|
||||
const p = payload as Extract<PetitionPayload, { type: 'UPDATE_PARAM' }>;
|
||||
return (
|
||||
<span className="text-gray-300">
|
||||
Set <span className="text-cyan-400 font-bold">{p.key}</span> = {' '}
|
||||
<span className="text-white font-bold">{String(p.value)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (t === 'BATCH_UPDATE_PARAMS') {
|
||||
const p = payload as Extract<PetitionPayload, { type: 'BATCH_UPDATE_PARAMS' }>;
|
||||
return (
|
||||
<span className="text-gray-300">
|
||||
Update {p.updates?.length ?? 0} parameters atomically
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (t === 'ENABLE_FEATURE') {
|
||||
const p = payload as Extract<PetitionPayload, { type: 'ENABLE_FEATURE' }>;
|
||||
return <span className="text-gray-300">Enable feature <span className="text-green-400">{p.feature}</span></span>;
|
||||
}
|
||||
if (t === 'DISABLE_FEATURE') {
|
||||
const p = payload as Extract<PetitionPayload, { type: 'DISABLE_FEATURE' }>;
|
||||
return <span className="text-gray-300">Disable feature <span className="text-red-400">{p.feature}</span></span>;
|
||||
}
|
||||
return <span className="text-gray-500">Unknown payload type</span>;
|
||||
}
|
||||
|
||||
function PetitionRow({
|
||||
petition,
|
||||
now,
|
||||
onAction,
|
||||
}: {
|
||||
petition: PetitionState;
|
||||
now: number;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const style = STATUS_STYLE[petition.status] ?? STATUS_STYLE.not_found;
|
||||
const Icon = style.icon;
|
||||
const sigPct = petition.signature_threshold_at_filing > 0
|
||||
? (petition.signature_governance_weight / petition.signature_threshold_at_filing) * 100
|
||||
: 0;
|
||||
const totalVotes = petition.votes_for_weight + petition.votes_against_weight;
|
||||
const yesPct = totalVotes > 0
|
||||
? (petition.votes_for_weight / totalVotes) * 100
|
||||
: 0;
|
||||
const { state, result, submit } = useSignAndAppend();
|
||||
const busy = state === 'submitting';
|
||||
|
||||
const sign = useCallback(async () => {
|
||||
const built = buildPetitionSignPayload(petition.petition_id);
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [petition.petition_id, submit, onAction]);
|
||||
|
||||
const voteFor = useCallback(async () => {
|
||||
const built = buildPetitionVotePayload(petition.petition_id, 'for');
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [petition.petition_id, submit, onAction]);
|
||||
|
||||
const voteAgainst = useCallback(async () => {
|
||||
const built = buildPetitionVotePayload(petition.petition_id, 'against');
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [petition.petition_id, submit, onAction]);
|
||||
|
||||
const challenge = useCallback(async () => {
|
||||
const reason = window.prompt(
|
||||
'Constitutional challenge — describe why this petition violates the constitution:',
|
||||
);
|
||||
if (!reason || !reason.trim()) return;
|
||||
const built = buildChallengeFilePayload(petition.petition_id, reason.trim());
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [petition.petition_id, submit, onAction]);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-800 bg-black/40 p-3 hover:bg-black/60 transition-colors">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon size={14} className={style.color} />
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${style.color}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono truncate">
|
||||
{petition.petition_id.slice(0, 16)}…
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm mb-2">
|
||||
<PayloadSummary payload={petition.petition_payload} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Filer</div>
|
||||
<div className="text-gray-300 font-mono truncate" title={petition.filer_id}>
|
||||
{petition.filer_id.slice(0, 12)}…
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Filed</div>
|
||||
<div className="text-gray-300">{formatRelative(petition.filed_at, now)}</div>
|
||||
</div>
|
||||
{petition.status === 'signatures' && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-gray-500">
|
||||
Signatures: {petition.signature_governance_weight.toFixed(1)} / {petition.signature_threshold_at_filing.toFixed(1)}
|
||||
</div>
|
||||
<div className="h-1 bg-gray-800 mt-1 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-cyan-500 transition-all"
|
||||
style={{ width: `${Math.min(100, sigPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(petition.status === 'voting' || petition.status === 'challenge'
|
||||
|| petition.status === 'passed' || petition.status === 'executed') && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-gray-500">
|
||||
Vote: {petition.votes_for_weight.toFixed(1)} for / {petition.votes_against_weight.toFixed(1)} against
|
||||
</div>
|
||||
<div className="h-1 bg-gray-800 mt-1 overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all"
|
||||
style={{ width: `${yesPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${100 - yesPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{petition.voting_deadline && petition.status === 'voting' && (
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Voting closes {formatRelative(petition.voting_deadline, now)}
|
||||
</div>
|
||||
)}
|
||||
{petition.challenge_window_until && petition.status === 'challenge' && (
|
||||
<div className="text-xs text-amber-400 mt-2">
|
||||
Challenge window closes {formatRelative(petition.challenge_window_until, now)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{petition.status === 'signatures' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sign}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? 'Signing…' : 'Sign'}
|
||||
</button>
|
||||
)}
|
||||
{petition.status === 'voting' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={voteFor}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-green-700/50 bg-green-900/20 text-green-400 hover:bg-green-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Vote FOR'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={voteAgainst}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Vote AGAINST'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{petition.status === 'challenge' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={challenge}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
|
||||
title="File a constitutional challenge against this passed petition"
|
||||
>
|
||||
{busy ? '…' : 'Challenge'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && !result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePetitionForm({ onFiled }: { onFiled?: () => void }) {
|
||||
const [paramKey, setParamKey] = useState('');
|
||||
const [paramValue, setParamValue] = useState('');
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [filing, setFiling] = useState(false);
|
||||
const [previewResult, setPreviewResult] = useState<
|
||||
{ ok: true; changedKeys: string[]; newValues: Record<string, unknown> } |
|
||||
{ ok: false; reason: string } | null
|
||||
>(null);
|
||||
const [fileResult, setFileResult] = useState<
|
||||
{ ok: true; eventId: string } |
|
||||
{ ok: false; reason: string } | null
|
||||
>(null);
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!paramKey.trim()) return;
|
||||
setPreviewing(true);
|
||||
setPreviewResult(null);
|
||||
try {
|
||||
// Try numeric coercion; fall back to string. Backend validator
|
||||
// rejects type mismatches with a diagnostic — surfaces directly.
|
||||
let value: unknown = paramValue;
|
||||
const numeric = Number(paramValue);
|
||||
if (paramValue.trim() !== '' && !Number.isNaN(numeric)) {
|
||||
value = numeric;
|
||||
} else if (paramValue.trim().toLowerCase() === 'true') {
|
||||
value = true;
|
||||
} else if (paramValue.trim().toLowerCase() === 'false') {
|
||||
value = false;
|
||||
}
|
||||
const payload: PetitionPayload = {
|
||||
type: 'UPDATE_PARAM',
|
||||
key: paramKey.trim(),
|
||||
value,
|
||||
};
|
||||
const res = await previewPetitionPayload(payload);
|
||||
if (res.ok) {
|
||||
setPreviewResult({
|
||||
ok: true,
|
||||
changedKeys: res.changed_keys ?? [],
|
||||
newValues: res.new_values ?? {},
|
||||
});
|
||||
} else {
|
||||
setPreviewResult({ ok: false, reason: res.reason ?? 'unknown_error' });
|
||||
}
|
||||
} catch (err) {
|
||||
setPreviewResult({
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : 'network_error',
|
||||
});
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
}, [paramKey, paramValue]);
|
||||
|
||||
const buildPayload = useCallback((): PetitionPayload | null => {
|
||||
if (!paramKey.trim()) return null;
|
||||
let value: unknown = paramValue;
|
||||
const numeric = Number(paramValue);
|
||||
if (paramValue.trim() !== '' && !Number.isNaN(numeric)) {
|
||||
value = numeric;
|
||||
} else if (paramValue.trim().toLowerCase() === 'true') {
|
||||
value = true;
|
||||
} else if (paramValue.trim().toLowerCase() === 'false') {
|
||||
value = false;
|
||||
}
|
||||
return { type: 'UPDATE_PARAM', key: paramKey.trim(), value };
|
||||
}, [paramKey, paramValue]);
|
||||
|
||||
const handleFile = useCallback(async () => {
|
||||
const inner = buildPayload();
|
||||
if (!inner) return;
|
||||
setFiling(true);
|
||||
setFileResult(null);
|
||||
try {
|
||||
// Generate a fresh petition_id deterministically from the payload
|
||||
// + timestamp so refile attempts produce distinct IDs.
|
||||
const petitionId = `pet-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const built = buildPetitionFilePayload(petitionId, inner);
|
||||
const res = await signAndAppend({
|
||||
event_type: built.event_type,
|
||||
payload: built.payload,
|
||||
});
|
||||
if (res.ok) {
|
||||
setFileResult({ ok: true, eventId: res.event.event_id });
|
||||
onFiled?.();
|
||||
} else {
|
||||
setFileResult({ ok: false, reason: res.reason });
|
||||
}
|
||||
} catch (err) {
|
||||
setFileResult({
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : 'unknown_error',
|
||||
});
|
||||
} finally {
|
||||
setFiling(false);
|
||||
}
|
||||
}, [buildPayload, onFiled]);
|
||||
|
||||
return (
|
||||
<div className="border border-cyan-900/50 bg-cyan-900/5 p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText size={14} className="text-cyan-400" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-cyan-400">
|
||||
File or Preview a Petition
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-3">
|
||||
<span className="text-cyan-400 font-bold">Preview</span> runs the
|
||||
governance DSL executor without touching the chain — the diagnostic
|
||||
on failure is shown verbatim.
|
||||
{' '}<span className="text-amber-400 font-bold">File</span> signs the
|
||||
same payload with your local node key and posts it to{' '}
|
||||
<span className="font-mono">/api/infonet/append</span>; the secure
|
||||
entry point ({' '}<span className="font-mono">Infonet.append</span>)
|
||||
verifies signature, replay, sequence, and binding before the event
|
||||
lands.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1 block">CONFIG key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={paramKey}
|
||||
onChange={(e) => setParamKey(e.target.value)}
|
||||
placeholder="e.g. vote_decay_days"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-sm text-white font-mono focus:border-cyan-500 focus:outline-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1 block">New value</label>
|
||||
<input
|
||||
type="text"
|
||||
value={paramValue}
|
||||
onChange={(e) => setParamValue(e.target.value)}
|
||||
placeholder="e.g. 30 / true / argon2id"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-sm text-white font-mono focus:border-cyan-500 focus:outline-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
disabled={previewing || !paramKey.trim()}
|
||||
className="px-3 py-1 bg-cyan-900/30 border border-cyan-700/50 text-cyan-400 hover:bg-cyan-900/50 hover:text-cyan-300 transition-colors text-xs uppercase tracking-wider disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{previewing ? 'Validating…' : 'Preview'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFile}
|
||||
disabled={filing || !paramKey.trim()}
|
||||
className="px-3 py-1 bg-amber-900/30 border border-amber-700/50 text-amber-400 hover:bg-amber-900/50 hover:text-amber-300 transition-colors text-xs uppercase tracking-wider disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Sign with the local node key + post to /api/infonet/append"
|
||||
>
|
||||
{filing ? 'Filing…' : 'File Petition'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fileResult && fileResult.ok && (
|
||||
<div className="mt-3 border border-green-900/50 bg-green-900/10 p-2 text-xs">
|
||||
<div className="text-green-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<CheckCircle2 size={12} /> PETITION FILED
|
||||
</div>
|
||||
<div className="text-gray-300 font-mono break-all">
|
||||
event_id: {fileResult.eventId}
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
The petition is now in the SIGNATURES phase. Other nodes can
|
||||
sign with <span className="font-mono">petition_sign</span>;
|
||||
voting opens once 25% oracle_rep_active worth of signatures land.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fileResult && !fileResult.ok && (
|
||||
<div className="mt-3 border border-red-900/50 bg-red-900/10 p-2 text-xs">
|
||||
<div className="text-red-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<AlertCircle size={12} /> FILING REJECTED
|
||||
</div>
|
||||
<div className="text-gray-300 font-mono break-all">{fileResult.reason}</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
Common causes: local identity not initialized
|
||||
(open the InfonetTerminal first), filer rep below
|
||||
petition_filing_cost, or the chain rejected the signed event.
|
||||
Use Preview first to confirm the payload validates.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewResult && previewResult.ok && (
|
||||
<div className="mt-3 border border-green-900/50 bg-green-900/10 p-2 text-xs">
|
||||
<div className="text-green-400 font-bold uppercase tracking-wider mb-1">
|
||||
VALIDATION PASSED
|
||||
</div>
|
||||
<div className="text-gray-300">
|
||||
Would change keys: {previewResult.changedKeys.map((k) => (
|
||||
<span key={k} className="text-cyan-400 font-mono mr-2">{k}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
Filing this petition costs the configured petition_filing_cost in common rep.
|
||||
Production filing requires a signed event — this is the validation preview only.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{previewResult && !previewResult.ok && (
|
||||
<div className="mt-3 border border-red-900/50 bg-red-900/10 p-2 text-xs">
|
||||
<div className="text-red-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<AlertCircle size={12} /> VALIDATION REJECTED
|
||||
</div>
|
||||
<div className="text-gray-300 font-mono">{previewResult.reason}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PetitionsView({ onBack }: PetitionsViewProps) {
|
||||
const [petitions, setPetitions] = useState<PetitionState[] | null>(null);
|
||||
const [now, setNow] = useState(Date.now() / 1000);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchPetitions();
|
||||
setPetitions(data.petitions);
|
||||
setNow(data.now);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasActivePhase = (petitions || []).some((p) =>
|
||||
p.status === 'signatures' || p.status === 'voting' || p.status === 'challenge',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!petitions) return null;
|
||||
const active = petitions.filter((p) =>
|
||||
['signatures', 'voting', 'challenge'].includes(p.status),
|
||||
);
|
||||
const passed = petitions.filter((p) =>
|
||||
['passed', 'executed'].includes(p.status),
|
||||
);
|
||||
const closed = petitions.filter((p) =>
|
||||
['failed_signatures', 'failed_vote', 'voided_challenge'].includes(p.status),
|
||||
);
|
||||
return { active, passed, closed };
|
||||
}, [petitions]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-400 hover:text-cyan-300 transition-colors text-sm"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
BACK TO TERMINAL
|
||||
</button>
|
||||
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Vote size={16} />
|
||||
BALLOT — Governance Petitions
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30"
|
||||
>
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-6">
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
Petitions amend protocol parameters via the type-safe governance DSL.
|
||||
Lifecycle: <span className="text-cyan-400">SIGNATURES</span> (14d, 25% oracle_rep_active threshold)
|
||||
→ <span className="text-blue-400">VOTING</span> (7d, 67% supermajority + 30% quorum)
|
||||
→ <span className="text-amber-400">CHALLENGE</span> (48h constitutional challenge window)
|
||||
→ <span className="text-green-400">EXECUTED</span>.
|
||||
The DSL executor rejects unknown CONFIG keys, type mismatches, out-of-bounds
|
||||
values, and IMMUTABLE_PRINCIPLES writes — see the validation preview below.
|
||||
</div>
|
||||
|
||||
<FilePetitionForm onFiled={() => void reload()} />
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={12} />
|
||||
<span className="font-bold">Failed to load petitions</span>
|
||||
</div>
|
||||
<div className="text-gray-400 mt-1 font-mono">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped && grouped.active.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">
|
||||
Active Petitions ({grouped.active.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{grouped.active.map((p) => (
|
||||
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped && grouped.passed.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-green-400 mb-2">
|
||||
Passed Petitions ({grouped.passed.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{grouped.passed.map((p) => (
|
||||
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped && grouped.closed.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-gray-500 mb-2">
|
||||
Closed (Failed / Voided) ({grouped.closed.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{grouped.closed.map((p) => (
|
||||
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped && petitions && petitions.length === 0 && !loading && (
|
||||
<div className="border border-gray-800 bg-black/40 p-6 text-center">
|
||||
<div className="text-gray-500 text-sm mb-1">No petitions on the chain yet.</div>
|
||||
<div className="text-gray-600 text-xs">
|
||||
File one with the Preview tool above to see the lifecycle in action.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, User, Eye, EyeOff, Wallet, Activity, ShieldCheck, AlertCircle } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { exportWormholeDmInvite } from '@/mesh/wormholeIdentityClient';
|
||||
|
||||
interface ProfileViewProps {
|
||||
onBack: () => void;
|
||||
@@ -49,6 +51,11 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
|
||||
const [showTransactions, setShowTransactions] = useState(false);
|
||||
const [reputation, setReputation] = useState<ReputationSummary>(EMPTY_REPUTATION);
|
||||
const [oracleProfile, setOracleProfile] = useState<OracleProfileSummary>(EMPTY_ORACLE_PROFILE);
|
||||
const [dmInviteBusy, setDmInviteBusy] = useState(false);
|
||||
const [dmInviteBlob, setDmInviteBlob] = useState('');
|
||||
const [dmInviteQrSrc, setDmInviteQrSrc] = useState('');
|
||||
const [dmInviteFingerprint, setDmInviteFingerprint] = useState('');
|
||||
const [dmInviteStatus, setDmInviteStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -118,6 +125,40 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
|
||||
};
|
||||
}, [nodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!dmInviteBlob) {
|
||||
setDmInviteQrSrc('');
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
void QRCode.toDataURL(dmInviteBlob, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: 320,
|
||||
color: {
|
||||
dark: '#34d399',
|
||||
light: '#05080d',
|
||||
},
|
||||
})
|
||||
.then((dataUrl) => {
|
||||
if (active) {
|
||||
setDmInviteQrSrc(dataUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setDmInviteQrSrc('');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [dmInviteBlob]);
|
||||
|
||||
const displayNodeId = nodeId?.trim() || 'NOT PROVISIONED';
|
||||
const displayPersona = persona?.trim() || 'unassigned';
|
||||
const creditsReference = publicKey?.trim() || 'Not provisioned';
|
||||
@@ -141,6 +182,45 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
|
||||
const oracleRepLocked = oracleProfile.oracle_rep_locked;
|
||||
const oracleProgress = oracleRepTotal > 0 ? Math.max(0, Math.min(100, (oracleRep / oracleRepTotal) * 100)) : 0;
|
||||
|
||||
const handleGenerateDmInvite = async () => {
|
||||
setDmInviteBusy(true);
|
||||
setDmInviteStatus(null);
|
||||
try {
|
||||
const exported = await exportWormholeDmInvite();
|
||||
setDmInviteBlob(JSON.stringify(exported, null, 2));
|
||||
setDmInviteFingerprint(String(exported.trust_fingerprint || ''));
|
||||
setDmInviteStatus({
|
||||
type: 'ok',
|
||||
text: 'Signed DM invite generated. Share it only over a trusted out-of-band channel.',
|
||||
});
|
||||
} catch (error) {
|
||||
setDmInviteStatus({
|
||||
type: 'err',
|
||||
text: error instanceof Error ? error.message : 'dm_invite_export_failed',
|
||||
});
|
||||
} finally {
|
||||
setDmInviteBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDmInvite = async () => {
|
||||
if (!dmInviteBlob || !navigator?.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(dmInviteBlob);
|
||||
setDmInviteStatus({
|
||||
type: 'ok',
|
||||
text: 'Signed DM invite copied to clipboard.',
|
||||
});
|
||||
} catch (error) {
|
||||
setDmInviteStatus({
|
||||
type: 'err',
|
||||
text: error instanceof Error ? error.message : 'clipboard_write_failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
@@ -325,6 +405,76 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<ShieldCheck size={16} className="mr-2" /> FIRST-CONTACT BOOTSTRAP
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400 leading-[1.7]">
|
||||
Export a signed DM invite for trusted out-of-band exchange. This pins first contact to
|
||||
your messaging identity instead of plain first-sight TOFU. It does not link wallet,
|
||||
reputation, or other personas.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => void handleGenerateDmInvite()}
|
||||
disabled={dmInviteBusy}
|
||||
className="px-4 py-2 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
{dmInviteBusy ? 'Generating...' : 'Generate Signed DM Invite'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleCopyDmInvite()}
|
||||
disabled={!dmInviteBlob}
|
||||
className="px-4 py-2 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
Copy Invite
|
||||
</button>
|
||||
</div>
|
||||
{dmInviteFingerprint && (
|
||||
<div className="text-sm text-emerald-300 font-mono">
|
||||
Trust fingerprint: {dmInviteFingerprint}
|
||||
</div>
|
||||
)}
|
||||
{dmInviteStatus && (
|
||||
<div
|
||||
className={`px-3 py-2 border text-sm ${
|
||||
dmInviteStatus.type === 'ok'
|
||||
? 'border-emerald-500/30 bg-emerald-950/20 text-emerald-300'
|
||||
: 'border-red-500/30 bg-red-950/20 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{dmInviteStatus.text}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={dmInviteBlob}
|
||||
readOnly
|
||||
className="w-full min-h-[220px] bg-[#0a0a0a] border border-gray-800 px-4 py-3 text-sm text-gray-300 font-mono outline-none"
|
||||
placeholder="Generate a signed DM invite to display the export blob here."
|
||||
spellCheck={false}
|
||||
/>
|
||||
{dmInviteQrSrc && (
|
||||
<div className="border border-emerald-500/20 bg-[#0a0a0a] p-4">
|
||||
<div className="text-xs text-emerald-300 uppercase tracking-[0.18em] mb-3">
|
||||
QR Invite
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<img
|
||||
src={dmInviteQrSrc}
|
||||
alt="Signed DM invite QR"
|
||||
className="w-[320px] max-w-full border border-gray-800 bg-black p-3"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 text-center leading-[1.65] max-w-[32rem]">
|
||||
Scan this over a trusted out-of-band channel. The QR carries the same signed DM
|
||||
invite shown above, including the trust fingerprint and signature envelope.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<Wallet size={16} className="mr-2" /> CREDITS LEDGER
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, FileText, Scale, Loader, AlertCircle, CheckCircle2, ShieldOff } from 'lucide-react';
|
||||
import {
|
||||
buildDisputeOpenPayload,
|
||||
buildDisputeStakePayload,
|
||||
buildEvidenceSubmitPayload,
|
||||
buildResolutionStakePayload,
|
||||
fetchMarketState,
|
||||
previewMarketResolution,
|
||||
signAndAppend,
|
||||
type AppendResult,
|
||||
type DisputeSummary,
|
||||
type MarketState,
|
||||
type ResolutionPreview,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface ResolutionViewProps {
|
||||
marketId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const PHASE_STYLE: Record<string, { color: string; label: string }> = {
|
||||
predicting: { color: 'text-cyan-400', label: 'PREDICTING' },
|
||||
evidence: { color: 'text-amber-400', label: 'EVIDENCE WINDOW' },
|
||||
resolving: { color: 'text-blue-400', label: 'RESOLVING' },
|
||||
final: { color: 'text-green-400', label: 'FINAL' },
|
||||
invalid: { color: 'text-red-400', label: 'INVALID' },
|
||||
};
|
||||
|
||||
function DisputeRow({
|
||||
dispute,
|
||||
onAction,
|
||||
}: {
|
||||
dispute: DisputeSummary;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const [side, setSide] = useState<'confirm' | 'reverse'>('reverse');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [repType, setRepType] = useState<'oracle' | 'common'>('oracle');
|
||||
const action = useSignAndAppend();
|
||||
const busy = action.state === 'submitting';
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const amt = Number(amount);
|
||||
if (!Number.isFinite(amt) || amt <= 0) return;
|
||||
const built = buildDisputeStakePayload(dispute.dispute_id, side, amt, repType);
|
||||
const res = await action.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setAmount('');
|
||||
onAction();
|
||||
}
|
||||
}, [amount, side, repType, dispute.dispute_id, action, onAction]);
|
||||
|
||||
return (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-red-400 font-bold">DISPUTE</span>
|
||||
{dispute.is_resolved ? (
|
||||
<span
|
||||
className={
|
||||
dispute.resolved_outcome === 'reversed' ? 'text-red-400' : 'text-green-400'
|
||||
}
|
||||
>
|
||||
{dispute.resolved_outcome?.toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-400">PENDING</span>
|
||||
)}
|
||||
<span className="text-gray-500 font-mono truncate">
|
||||
{dispute.dispute_id.slice(0, 12)}…
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-300">
|
||||
Challenger: <span className="font-mono">{dispute.challenger_id.slice(0, 12)}…</span>
|
||||
{' — stake '}{dispute.challenger_stake.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
confirm: {dispute.confirm_stakes.length} stakes •
|
||||
reverse: {dispute.reverse_stakes.length} stakes
|
||||
</div>
|
||||
|
||||
{!dispute.is_resolved && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<select
|
||||
value={side}
|
||||
onChange={(e) => setSide(e.target.value as 'confirm' | 'reverse')}
|
||||
title="Dispute stake side"
|
||||
aria-label="Dispute stake side"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="confirm">CONFIRM</option>
|
||||
<option value="reverse">REVERSE</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="amount"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-24"
|
||||
/>
|
||||
<select
|
||||
value={repType}
|
||||
onChange={(e) => setRepType(e.target.value as 'oracle' | 'common')}
|
||||
title="Reputation type to stake"
|
||||
aria-label="Reputation type to stake"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="oracle">oracle</option>
|
||||
<option value="common">common</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || !amount}
|
||||
className="px-2 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? 'Staking…' : 'Stake'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.result && !action.result.ok && (
|
||||
<div className="text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{action.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResolutionView({ marketId, onBack }: ResolutionViewProps) {
|
||||
const [state, setState] = useState<MarketState | null>(null);
|
||||
const [preview, setPreview] = useState<ResolutionPreview['preview'] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Resolution-stake form state.
|
||||
const [stakeSide, setStakeSide] = useState<'yes' | 'no' | 'data_unavailable'>('yes');
|
||||
const [stakeAmount, setStakeAmount] = useState('');
|
||||
const [stakeRepType, setStakeRepType] = useState<'oracle' | 'common'>('oracle');
|
||||
|
||||
// Dispute-open form state.
|
||||
const [disputeStake, setDisputeStake] = useState('');
|
||||
const [disputeReason, setDisputeReason] = useState('');
|
||||
|
||||
// Evidence-submit form state (active during EVIDENCE phase).
|
||||
const [evidenceOutcome, setEvidenceOutcome] = useState<'yes' | 'no'>('yes');
|
||||
const [evidenceSourceDesc, setEvidenceSourceDesc] = useState('');
|
||||
const [evidenceHashesInput, setEvidenceHashesInput] = useState('');
|
||||
const [evidenceBond, setEvidenceBond] = useState('2');
|
||||
const [evidenceSubmitting, setEvidenceSubmitting] = useState(false);
|
||||
const [evidenceResult, setEvidenceResult] = useState<AppendResult | null>(null);
|
||||
|
||||
const stakeAction = useSignAndAppend();
|
||||
const disputeAction = useSignAndAppend();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [s, p] = await Promise.all([
|
||||
fetchMarketState(marketId),
|
||||
previewMarketResolution(marketId).catch(() => null),
|
||||
]);
|
||||
setState(s);
|
||||
setPreview(p?.preview ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [marketId]);
|
||||
|
||||
const submitStake = useCallback(async () => {
|
||||
const amt = Number(stakeAmount);
|
||||
if (!Number.isFinite(amt) || amt <= 0) return;
|
||||
const built = buildResolutionStakePayload(marketId, stakeSide, amt, stakeRepType);
|
||||
const res = await stakeAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setStakeAmount('');
|
||||
void reload();
|
||||
}
|
||||
}, [stakeAmount, stakeSide, stakeRepType, marketId, stakeAction, reload]);
|
||||
|
||||
const submitDispute = useCallback(async () => {
|
||||
const stake = Number(disputeStake);
|
||||
if (!Number.isFinite(stake) || stake <= 0) return;
|
||||
if (!disputeReason.trim()) return;
|
||||
const built = buildDisputeOpenPayload(marketId, stake, disputeReason.trim());
|
||||
const res = await disputeAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setDisputeStake('');
|
||||
setDisputeReason('');
|
||||
void reload();
|
||||
}
|
||||
}, [disputeStake, disputeReason, marketId, disputeAction, reload]);
|
||||
|
||||
const submitEvidence = useCallback(async () => {
|
||||
if (!evidenceSourceDesc.trim()) return;
|
||||
const hashes = evidenceHashesInput
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (hashes.length === 0) return;
|
||||
const bond = Number(evidenceBond);
|
||||
if (!Number.isFinite(bond) || bond < 0) return;
|
||||
setEvidenceSubmitting(true);
|
||||
setEvidenceResult(null);
|
||||
try {
|
||||
const built = await buildEvidenceSubmitPayload({
|
||||
marketId,
|
||||
claimedOutcome: evidenceOutcome,
|
||||
evidenceHashes: hashes,
|
||||
sourceDescription: evidenceSourceDesc.trim(),
|
||||
bond,
|
||||
});
|
||||
const res = await signAndAppend({
|
||||
event_type: built.event_type,
|
||||
payload: built.payload,
|
||||
});
|
||||
setEvidenceResult(res);
|
||||
if (res.ok) {
|
||||
setEvidenceSourceDesc('');
|
||||
setEvidenceHashesInput('');
|
||||
void reload();
|
||||
}
|
||||
} catch (err) {
|
||||
setEvidenceResult({
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : 'unknown_error',
|
||||
});
|
||||
} finally {
|
||||
setEvidenceSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
evidenceOutcome, evidenceSourceDesc, evidenceHashesInput, evidenceBond,
|
||||
marketId, reload,
|
||||
]);
|
||||
|
||||
const phase = state ? PHASE_STYLE[state.status] : null;
|
||||
const inEvidence = state?.status === 'evidence';
|
||||
const inResolving = state?.status === 'resolving';
|
||||
const isFinal = state?.status === 'final';
|
||||
const hasActivePhase = inEvidence || inResolving || isFinal;
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
|
||||
<ChevronLeft size={14} className="mr-1" /> BACK
|
||||
</button>
|
||||
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Scale size={16} /> RESOLUTION — {marketId}
|
||||
</div>
|
||||
<button onClick={() => void reload()} disabled={loading} className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30">
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<AlertCircle size={12} className="inline mr-1" />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && phase && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className={`text-xs font-bold uppercase tracking-wider ${phase.color} mb-2`}>
|
||||
PHASE: {phase.label}
|
||||
{state.was_reversed && (
|
||||
<span className="ml-2 text-red-400">⚠ REVERSED BY DISPUTE</span>
|
||||
)}
|
||||
</div>
|
||||
{state.snapshot && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Frozen Predictors</div>
|
||||
<div className="text-white">{(state.snapshot.frozen_participant_count as number) ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Frozen Total Stake</div>
|
||||
<div className="text-white">{(state.snapshot.frozen_total_stake as number)?.toFixed?.(2) ?? '0.00'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Excluded Predictors</div>
|
||||
<div className="text-white">{state.excluded_predictor_ids.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.evidence_bundles.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
|
||||
<FileText size={12} /> Evidence Bundles ({state.evidence_bundles.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.evidence_bundles.map((b) => (
|
||||
<div key={b.submission_hash} className="border border-gray-800 bg-black/40 p-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className={`font-bold ${b.claimed_outcome === 'yes' ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{b.claimed_outcome.toUpperCase()}
|
||||
</span>
|
||||
{b.is_first_for_side && (
|
||||
<span className="text-amber-400 text-xs">★ FIRST-FOR-SIDE BONUS</span>
|
||||
)}
|
||||
<span className="text-gray-500 font-mono truncate">
|
||||
{b.node_id.slice(0, 12)}…
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-300 mb-1">{b.source_description || '(no description)'}</div>
|
||||
<div className="text-gray-500 font-mono">
|
||||
bond: {b.bond} • {b.evidence_hashes.length} hash{b.evidence_hashes.length === 1 ? '' : 'es'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.disputes.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
|
||||
<ShieldOff size={12} /> Disputes ({state.disputes.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.disputes.map((d) => (
|
||||
<DisputeRow
|
||||
key={d.dispute_id}
|
||||
dispute={d}
|
||||
onAction={() => void reload()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inEvidence && (
|
||||
<div className="border border-amber-900/50 bg-amber-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
|
||||
<FileText size={12} /> Submit Evidence
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Pay an evidence bond (≥
|
||||
<span className="font-mono"> evidence_bond_cost</span>{' '}
|
||||
oracle rep). The bond is returned if your claimed side wins;
|
||||
forfeited otherwise. The first submitter per side gets a small
|
||||
bonus from the losing pool when the market resolves on their
|
||||
side. Hashes use the canonical content + submission scheme;
|
||||
both are computed locally before signing.
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={evidenceOutcome}
|
||||
onChange={(e) => setEvidenceOutcome(e.target.value as 'yes' | 'no')}
|
||||
title="Claimed outcome"
|
||||
aria-label="Claimed outcome"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="yes">YES</option>
|
||||
<option value="no">NO</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={evidenceBond}
|
||||
onChange={(e) => setEvidenceBond(e.target.value)}
|
||||
placeholder="bond"
|
||||
title="Bond amount in oracle rep"
|
||||
aria-label="Bond amount"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-24"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitEvidence}
|
||||
disabled={
|
||||
evidenceSubmitting ||
|
||||
!evidenceSourceDesc.trim() ||
|
||||
!evidenceHashesInput.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
|
||||
>
|
||||
{evidenceSubmitting ? 'Submitting…' : 'Submit Evidence'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={evidenceSourceDesc}
|
||||
onChange={(e) => setEvidenceSourceDesc(e.target.value)}
|
||||
placeholder="source description (what + where)"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={evidenceHashesInput}
|
||||
onChange={(e) => setEvidenceHashesInput(e.target.value)}
|
||||
placeholder="evidence hashes (comma- or space-separated; ipfs://… or sha256:…)"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
/>
|
||||
</div>
|
||||
{evidenceResult && !evidenceResult.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{evidenceResult.reason}
|
||||
</div>
|
||||
)}
|
||||
{evidenceResult && evidenceResult.ok && (
|
||||
<div className="text-xs text-green-400 font-mono mt-2 break-all">
|
||||
<CheckCircle2 size={10} className="inline mr-1" />
|
||||
evidence submitted — event_id {String(evidenceResult.event.event_id).slice(0, 16)}…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inResolving && (
|
||||
<div className="border border-blue-900/50 bg-blue-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-blue-400 mb-2 flex items-center gap-1">
|
||||
<Scale size={12} /> Stake on Resolution
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Pick a side and stake oracle (or common) rep. ≥75% of oracle stake on
|
||||
one side reaches supermajority. <span className="text-amber-400">data_unavailable</span>{' '}
|
||||
triggers phantom-evidence slashing if it crosses 33%.
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<select
|
||||
value={stakeSide}
|
||||
onChange={(e) => setStakeSide(e.target.value as 'yes' | 'no' | 'data_unavailable')}
|
||||
title="Resolution stake side"
|
||||
aria-label="Resolution stake side"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="yes">YES</option>
|
||||
<option value="no">NO</option>
|
||||
<option value="data_unavailable">DATA_UNAVAILABLE</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={stakeAmount}
|
||||
onChange={(e) => setStakeAmount(e.target.value)}
|
||||
placeholder="amount"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
|
||||
/>
|
||||
<select
|
||||
value={stakeRepType}
|
||||
onChange={(e) => setStakeRepType(e.target.value as 'oracle' | 'common')}
|
||||
title="Reputation type to stake"
|
||||
aria-label="Reputation type to stake"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="oracle">oracle rep</option>
|
||||
<option value="common">common rep</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitStake}
|
||||
disabled={stakeAction.state === 'submitting' || !stakeAmount}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-blue-700/50 bg-blue-900/20 text-blue-400 hover:bg-blue-900/40 disabled:opacity-30"
|
||||
>
|
||||
{stakeAction.state === 'submitting' ? 'Staking…' : 'Stake'}
|
||||
</button>
|
||||
</div>
|
||||
{stakeAction.result && !stakeAction.result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{stakeAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFinal && (
|
||||
<div className="border border-red-900/50 bg-red-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
|
||||
<ShieldOff size={12} /> Open a Dispute
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Bounded reversal: a successful dispute flips the effective outcome of
|
||||
THIS market only — never cascades to other markets. Oracle-rep simple
|
||||
majority decides; common rep can also be staked but doesn't decide
|
||||
the outcome.
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={disputeStake}
|
||||
onChange={(e) => setDisputeStake(e.target.value)}
|
||||
placeholder="challenger stake"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={disputeReason}
|
||||
onChange={(e) => setDisputeReason(e.target.value)}
|
||||
placeholder="reason (max 2000 chars)"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono flex-1 min-w-[200px]"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitDispute}
|
||||
disabled={
|
||||
disputeAction.state === 'submitting' ||
|
||||
!disputeStake || !disputeReason.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{disputeAction.state === 'submitting' ? 'Opening…' : 'Open Dispute'}
|
||||
</button>
|
||||
</div>
|
||||
{disputeAction.result && !disputeAction.result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{disputeAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="border border-cyan-900/50 bg-cyan-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2 flex items-center gap-1">
|
||||
<CheckCircle2 size={12} /> Resolution Preview (if closed now)
|
||||
</div>
|
||||
<div className="text-sm mb-2">
|
||||
Outcome: <span className={
|
||||
preview.outcome === 'yes' ? 'text-green-400 font-bold' :
|
||||
preview.outcome === 'no' ? 'text-red-400 font-bold' :
|
||||
'text-gray-400 font-bold'
|
||||
}>{preview.outcome.toUpperCase()}</span>
|
||||
<span className="text-gray-500 ml-2 font-mono">({preview.reason})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-gray-400">
|
||||
<div>
|
||||
Winners: {preview.stake_winnings.length} stake winnings,
|
||||
{' '}{preview.bond_returns.length} bond returns
|
||||
</div>
|
||||
<div>
|
||||
Forfeited: {preview.bond_forfeits.length} bonds •
|
||||
{' '}Burned: {preview.burned_amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
{preview.first_submitter_bonuses.length > 0 && (
|
||||
<div className="text-xs text-amber-400 mt-1">
|
||||
★ First-submitter bonuses: {preview.first_submitter_bonuses.map(b => `${b.node_id.slice(0,8)}…(${b.amount.toFixed(2)})`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, GitBranch, Server, AlertCircle, CheckCircle2, Loader } from 'lucide-react';
|
||||
import {
|
||||
buildUpgradeProposePayload,
|
||||
buildUpgradeSignPayload,
|
||||
buildUpgradeSignalReadyPayload,
|
||||
buildUpgradeVotePayload,
|
||||
fetchUpgrades,
|
||||
freshLocalId,
|
||||
type UpgradeProposalSummary,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface UpgradeViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, { color: string; label: string }> = {
|
||||
signatures: { color: 'text-cyan-400', label: 'COLLECTING SIGNATURES' },
|
||||
voting: { color: 'text-blue-400', label: 'VOTING' },
|
||||
challenge: { color: 'text-amber-400', label: 'CHALLENGE WINDOW' },
|
||||
activation: { color: 'text-purple-400', label: 'AWAITING HEAVY-NODE READINESS' },
|
||||
activated: { color: 'text-green-500', label: 'ACTIVATED' },
|
||||
failed_signatures: { color: 'text-red-400', label: 'FAILED — SIGNATURES' },
|
||||
failed_vote: { color: 'text-red-400', label: 'FAILED — VOTE' },
|
||||
voided_challenge: { color: 'text-red-500', label: 'VOIDED BY CHALLENGE' },
|
||||
failed_activation: { color: 'text-red-400', label: 'FAILED — ACTIVATION' },
|
||||
not_found: { color: 'text-gray-500', label: 'NOT FOUND' },
|
||||
};
|
||||
|
||||
function UpgradeRow({
|
||||
proposal,
|
||||
onAction,
|
||||
}: {
|
||||
proposal: UpgradeProposalSummary;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const style = STATUS_STYLE[proposal.status] ?? STATUS_STYLE.not_found;
|
||||
const totalVotes = proposal.votes_for_weight + proposal.votes_against_weight;
|
||||
const yesPct = totalVotes > 0 ? (proposal.votes_for_weight / totalVotes) * 100 : 0;
|
||||
const readinessPct = (proposal.readiness_fraction || 0) * 100;
|
||||
const { state, result, submit } = useSignAndAppend();
|
||||
const busy = state === 'submitting';
|
||||
|
||||
const sign = useCallback(async () => {
|
||||
const built = buildUpgradeSignPayload(proposal.proposal_id);
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [proposal.proposal_id, submit, onAction]);
|
||||
|
||||
const voteFor = useCallback(async () => {
|
||||
const built = buildUpgradeVotePayload(proposal.proposal_id, 'for');
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [proposal.proposal_id, submit, onAction]);
|
||||
|
||||
const voteAgainst = useCallback(async () => {
|
||||
const built = buildUpgradeVotePayload(proposal.proposal_id, 'against');
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [proposal.proposal_id, submit, onAction]);
|
||||
|
||||
const signalReady = useCallback(async () => {
|
||||
const built = buildUpgradeSignalReadyPayload(
|
||||
proposal.proposal_id,
|
||||
proposal.release_hash,
|
||||
);
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [proposal.proposal_id, proposal.release_hash, submit, onAction]);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={14} className={style.color} />
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${style.color}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
→ v{proposal.target_protocol_version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-2 font-mono break-all">
|
||||
release_hash: {proposal.release_hash.slice(0, 32)}…
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
|
||||
<div>
|
||||
<div className="text-gray-500">Proposer</div>
|
||||
<div className="text-gray-300 font-mono truncate">
|
||||
{proposal.proposer_id.slice(0, 16)}…
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Filed</div>
|
||||
<div className="text-gray-300">
|
||||
{proposal.filed_at ? new Date(proposal.filed_at * 1000).toLocaleDateString() : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(proposal.status === 'voting' || proposal.status === 'challenge'
|
||||
|| proposal.status === 'activation' || proposal.status === 'activated') && (
|
||||
<>
|
||||
<div className="text-xs text-gray-500 mb-1">
|
||||
Vote: {proposal.votes_for_weight.toFixed(1)} for / {proposal.votes_against_weight.toFixed(1)} against
|
||||
<span className="text-gray-600 ml-2">(80% supermajority required)</span>
|
||||
</div>
|
||||
<div className="h-1 bg-gray-800 mb-2 overflow-hidden flex">
|
||||
<div className="h-full bg-green-500" style={{ width: `${yesPct}%` }} />
|
||||
<div className="h-full bg-red-500" style={{ width: `${100 - yesPct}%` }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(proposal.status === 'activation' || proposal.status === 'activated') && (
|
||||
<>
|
||||
<div className="text-xs text-purple-400 mb-1 flex items-center gap-1">
|
||||
<Server size={11} />
|
||||
Heavy-Node readiness: {readinessPct.toFixed(1)}%
|
||||
<span className="text-gray-500 ml-2">(67% required for activation)</span>
|
||||
{proposal.readiness_threshold_met && (
|
||||
<span className="text-green-400 ml-2">✓ THRESHOLD MET</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 transition-all"
|
||||
style={{ width: `${Math.min(100, readinessPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{proposal.status === 'signatures' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sign}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? 'Signing…' : 'Sign'}
|
||||
</button>
|
||||
)}
|
||||
{proposal.status === 'voting' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={voteFor}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-green-700/50 bg-green-900/20 text-green-400 hover:bg-green-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Vote FOR'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={voteAgainst}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Vote AGAINST'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{proposal.status === 'activation' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={signalReady}
|
||||
disabled={busy}
|
||||
title="Signal that this Heavy Node has installed and verified the new release"
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Signal Ready'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && !result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProposeUpgradePanel({ onAction }: { onAction: () => void }) {
|
||||
const [releaseHash, setReleaseHash] = useState('');
|
||||
const [releaseDescription, setReleaseDescription] = useState('');
|
||||
const [targetProtocolVersion, setTargetProtocolVersion] = useState('');
|
||||
const { state, result, submit } = useSignAndAppend();
|
||||
const busy = state === 'submitting';
|
||||
|
||||
const propose = useCallback(async () => {
|
||||
const trimmedHash = releaseHash.trim().toLowerCase();
|
||||
const trimmedDesc = releaseDescription.trim();
|
||||
const trimmedVersion = targetProtocolVersion.trim();
|
||||
if (trimmedHash.length !== 64 || !/^[0-9a-f]{64}$/.test(trimmedHash)) return;
|
||||
if (!trimmedDesc) return;
|
||||
if (!trimmedVersion) return;
|
||||
const built = buildUpgradeProposePayload({
|
||||
proposalId: freshLocalId('upg'),
|
||||
releaseHash: trimmedHash,
|
||||
releaseDescription: trimmedDesc,
|
||||
targetProtocolVersion: trimmedVersion,
|
||||
});
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setReleaseHash('');
|
||||
setReleaseDescription('');
|
||||
setTargetProtocolVersion('');
|
||||
onAction();
|
||||
}
|
||||
}, [releaseHash, releaseDescription, targetProtocolVersion, submit, onAction]);
|
||||
|
||||
return (
|
||||
<div className="border border-purple-900/50 bg-purple-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-purple-400 font-bold mb-2">
|
||||
File Upgrade Proposal
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-3 leading-relaxed">
|
||||
Filing requires <span className="text-purple-400">upgrade_filing_cost</span> common rep and a
|
||||
SHA-256 hash of the verified release artifact. After filing, the proposal collects
|
||||
signatures, then enters voting (80% supermajority / 40% quorum), then the challenge window,
|
||||
then awaits 67% Heavy-Node readiness signal before activation.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={releaseHash}
|
||||
onChange={(e) => setReleaseHash(e.target.value)}
|
||||
placeholder="release_hash (64 hex chars)"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono text-xs col-span-1 md:col-span-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={targetProtocolVersion}
|
||||
onChange={(e) => setTargetProtocolVersion(e.target.value)}
|
||||
placeholder="target protocol_version"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={releaseDescription}
|
||||
onChange={(e) => setReleaseDescription(e.target.value)}
|
||||
placeholder="release_description — what changes / event types / formulas does this introduce?"
|
||||
rows={2}
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white text-xs w-full mb-2"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={propose}
|
||||
disabled={busy}
|
||||
className="px-3 py-1 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? 'Filing…' : 'Propose Upgrade'}
|
||||
</button>
|
||||
{result && result.ok && (
|
||||
<span className="text-xs text-green-400 flex items-center gap-1">
|
||||
<CheckCircle2 size={11} /> Filed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result && !result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpgradeView({ onBack }: UpgradeViewProps) {
|
||||
const [upgrades, setUpgrades] = useState<UpgradeProposalSummary[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchUpgrades();
|
||||
setUpgrades(data.upgrades);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasActivePhase = (upgrades || []).some((u) =>
|
||||
u.status === 'signatures' || u.status === 'voting' ||
|
||||
u.status === 'challenge' || u.status === 'activation',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-400 hover:text-cyan-300 transition-colors text-sm"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
BACK
|
||||
</button>
|
||||
<div className="text-sm text-purple-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<GitBranch size={16} />
|
||||
UPGRADE-HASH GOVERNANCE
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
className="text-xs text-gray-500 hover:text-purple-400 disabled:opacity-30"
|
||||
>
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
Protocol upgrades that need new logic / new event types / new formulas
|
||||
can't be expressed as parameter changes — they use upgrade-hash
|
||||
governance. The network votes on a software release's SHA-256 hash;
|
||||
Heavy Nodes that have downloaded and verified the release emit
|
||||
<span className="text-purple-400"> upgrade_signal_ready</span>. Once 67%
|
||||
of Heavy Nodes have signaled, the upgrade activates and protocol_version
|
||||
increments. Higher thresholds than param petitions: <span className="text-green-400">80% supermajority</span>,
|
||||
<span className="text-blue-400"> 40% quorum</span>,
|
||||
<span className="text-purple-400"> 67% Heavy-Node activation</span>.
|
||||
</div>
|
||||
|
||||
<ProposeUpgradePanel onAction={() => void reload()} />
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<AlertCircle size={12} className="inline mr-1" />
|
||||
<span className="font-bold">Failed to load:</span>
|
||||
<span className="text-gray-400 ml-2 font-mono">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upgrades && upgrades.length === 0 && !loading && (
|
||||
<div className="border border-gray-800 bg-black/40 p-6 text-center">
|
||||
<div className="text-gray-500 text-sm">No upgrade proposals on chain.</div>
|
||||
<div className="text-gray-600 text-xs mt-1">
|
||||
Filing requires <span className="text-purple-400">upgrade_filing_cost</span> common rep
|
||||
and a SHA-256 release hash.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upgrades?.map((u) => (
|
||||
<UpgradeRow key={u.proposal_id} proposal={u} onAction={() => void reload()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const LOCATIONS = [
|
||||
{ name: 'Night City', tz: 'America/Los_Angeles', tempC: 18 },
|
||||
{ name: 'Tokyo', tz: 'Asia/Tokyo', tempC: 22 },
|
||||
{ name: 'New York', tz: 'America/New_York', tempC: 25 },
|
||||
{ name: 'London', tz: 'Europe/London', tempC: 12 },
|
||||
{ name: 'Neo Seoul', tz: 'Asia/Seoul', tempC: 19 },
|
||||
];
|
||||
|
||||
export default function WeatherWidget() {
|
||||
const [locIdx, setLocIdx] = useState(0);
|
||||
const [isCelsius, setIsCelsius] = useState(false);
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,32 +10,19 @@ export default function WeatherWidget() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const loc = LOCATIONS[locIdx];
|
||||
const temp = isCelsius ? loc.tempC : Math.round(loc.tempC * 9/5 + 32);
|
||||
const tempUnit = isCelsius ? 'C' : 'F';
|
||||
|
||||
const timeString = time.toLocaleTimeString('en-US', { timeZone: loc.tz, hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const dateString = time.toLocaleDateString('en-US', { timeZone: loc.tz, month: 'short', day: 'numeric' });
|
||||
const timeString = time.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const dateString = time.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm md:text-xs text-gray-400 border border-gray-800 bg-gray-900/30 px-2 py-1 shrink-0 font-mono tracking-widest uppercase whitespace-nowrap">
|
||||
<span>{dateString} {timeString}</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span
|
||||
className="cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => setLocIdx((i) => (i + 1) % LOCATIONS.length)}
|
||||
title="Change Location & Timezone"
|
||||
>
|
||||
{loc.name}
|
||||
</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span
|
||||
className="cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => setIsCelsius(!isCelsius)}
|
||||
title="Toggle C / F"
|
||||
>
|
||||
{temp}°{tempUnit}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,15 @@ interface InfonetTerminalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
|
||||
}
|
||||
|
||||
export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: InfonetTerminalProps) {
|
||||
export default function InfonetTerminal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenLiveGate,
|
||||
onOpenDeadDrop,
|
||||
}: InfonetTerminalProps) {
|
||||
/* Close on Escape */
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -68,7 +74,12 @@ export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: Inf
|
||||
|
||||
{/* Shell content — fills remaining space, scrolls internally */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<InfonetShell isOpen={isOpen} onClose={onClose} onOpenLiveGate={onOpenLiveGate} />
|
||||
<InfonetShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onOpenLiveGate={onOpenLiveGate}
|
||||
onOpenDeadDrop={onOpenDeadDrop}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user