DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
221 lines
7.7 KiB
TypeScript
221 lines
7.7 KiB
TypeScript
/**
|
|
* Welcome / landing screen.
|
|
* - Node URL input with live ping + QR scanner
|
|
* - Create / Import account buttons
|
|
* Redirects to (app)/chats if key already loaded.
|
|
*/
|
|
|
|
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
View, Text, TextInput, Pressable,
|
|
ScrollView, Alert, ActivityIndicator,
|
|
} from 'react-native';
|
|
import { router } from 'expo-router';
|
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
|
import { useStore } from '@/lib/store';
|
|
import { saveSettings } from '@/lib/storage';
|
|
import { setNodeUrl, getNetStats } from '@/lib/api';
|
|
import { Button } from '@/components/ui/Button';
|
|
|
|
export default function WelcomeScreen() {
|
|
const keyFile = useStore(s => s.keyFile);
|
|
const settings = useStore(s => s.settings);
|
|
const setSettings = useStore(s => s.setSettings);
|
|
|
|
const [nodeInput, setNodeInput] = useState('');
|
|
const [scanning, setScanning] = useState(false);
|
|
const [checking, setChecking] = useState(false);
|
|
const [nodeOk, setNodeOk] = useState<boolean | null>(null);
|
|
|
|
const [permission, requestPermission] = useCameraPermissions();
|
|
|
|
useEffect(() => {
|
|
if (keyFile) router.replace('/(app)/chats');
|
|
}, [keyFile]);
|
|
|
|
useEffect(() => {
|
|
setNodeInput(settings.nodeUrl);
|
|
}, [settings.nodeUrl]);
|
|
|
|
const applyNode = useCallback(async (url: string) => {
|
|
const clean = url.trim().replace(/\/$/, '');
|
|
if (!clean) return;
|
|
setChecking(true);
|
|
setNodeOk(null);
|
|
setNodeUrl(clean);
|
|
try {
|
|
await getNetStats();
|
|
setNodeOk(true);
|
|
const next = { ...settings, nodeUrl: clean };
|
|
setSettings(next);
|
|
await saveSettings(next);
|
|
} catch {
|
|
setNodeOk(false);
|
|
} finally {
|
|
setChecking(false);
|
|
}
|
|
}, [settings, setSettings]);
|
|
|
|
const onQrScanned = useCallback(({ data }: { data: string }) => {
|
|
setScanning(false);
|
|
let url = data.trim();
|
|
try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {}
|
|
setNodeInput(url);
|
|
applyNode(url);
|
|
}, [applyNode]);
|
|
|
|
const openScanner = async () => {
|
|
if (!permission?.granted) {
|
|
const { granted } = await requestPermission();
|
|
if (!granted) {
|
|
Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.');
|
|
return;
|
|
}
|
|
}
|
|
setScanning(true);
|
|
};
|
|
|
|
// ── QR Scanner overlay ───────────────────────────────────────────────────
|
|
if (scanning) {
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: '#000' }}>
|
|
<CameraView
|
|
style={{ flex: 1 }}
|
|
facing="back"
|
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
|
onBarcodeScanned={onQrScanned}
|
|
/>
|
|
<View style={{
|
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<View style={{
|
|
width: 240, height: 240,
|
|
borderWidth: 2, borderColor: 'white', borderRadius: 16,
|
|
}} />
|
|
<Text style={{ color: 'white', marginTop: 20, opacity: 0.8 }}>
|
|
Point at a DChain node QR code
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
onPress={() => setScanning(false)}
|
|
style={{
|
|
position: 'absolute', top: 56, left: 16,
|
|
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20,
|
|
paddingHorizontal: 16, paddingVertical: 8,
|
|
}}
|
|
>
|
|
<Text style={{ color: 'white', fontSize: 16 }}>✕ Cancel</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ── Main screen ──────────────────────────────────────────────────────────
|
|
const statusColor = nodeOk === true ? '#3fb950' : nodeOk === false ? '#f85149' : '#8b949e';
|
|
|
|
return (
|
|
<ScrollView
|
|
style={{ flex: 1, backgroundColor: '#0d1117' }}
|
|
contentContainerStyle={{ flexGrow: 1, paddingHorizontal: 24, paddingTop: 80, paddingBottom: 40 }}
|
|
keyboardShouldPersistTaps="handled"
|
|
keyboardDismissMode="on-drag"
|
|
>
|
|
{/* Logo ─ takes remaining space above, centered */}
|
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
|
<View style={{
|
|
width: 96, height: 96, borderRadius: 24,
|
|
backgroundColor: '#2563eb', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<Text style={{ fontSize: 48 }}>⛓</Text>
|
|
</View>
|
|
<Text style={{ color: '#fff', fontSize: 36, fontWeight: '700', letterSpacing: -1 }}>
|
|
DChain
|
|
</Text>
|
|
<Text style={{ color: '#8b949e', textAlign: 'center', fontSize: 15, lineHeight: 22 }}>
|
|
Decentralised E2E-encrypted messenger.{'\n'}Your keys. Your messages.
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Bottom section ─ node input + buttons */}
|
|
<View style={{ gap: 12, marginTop: 40 }}>
|
|
|
|
{/* Node URL label */}
|
|
<Text style={{ color: '#8b949e', fontSize: 11, fontWeight: '600',
|
|
textTransform: 'uppercase', letterSpacing: 1.2, marginBottom: 2 }}>
|
|
Node URL
|
|
</Text>
|
|
|
|
{/* Input row */}
|
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
<View style={{
|
|
flex: 1, flexDirection: 'row', alignItems: 'center',
|
|
backgroundColor: '#21262d', borderWidth: 1, borderColor: '#30363d',
|
|
borderRadius: 12, paddingHorizontal: 12, gap: 8,
|
|
}}>
|
|
{/* Status dot */}
|
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: statusColor }} />
|
|
|
|
<TextInput
|
|
value={nodeInput}
|
|
onChangeText={t => { setNodeInput(t); setNodeOk(null); }}
|
|
onEndEditing={() => applyNode(nodeInput)}
|
|
onSubmitEditing={() => applyNode(nodeInput)}
|
|
placeholder="http://192.168.1.10:8081"
|
|
placeholderTextColor="#8b949e"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
keyboardType="url"
|
|
returnKeyType="done"
|
|
style={{ flex: 1, color: '#fff', fontSize: 14, paddingVertical: 14 }}
|
|
/>
|
|
|
|
{checking
|
|
? <ActivityIndicator size="small" color="#8b949e" />
|
|
: nodeOk === true
|
|
? <Text style={{ color: '#3fb950', fontSize: 16 }}>✓</Text>
|
|
: nodeOk === false
|
|
? <Text style={{ color: '#f85149', fontSize: 14 }}>✗</Text>
|
|
: null
|
|
}
|
|
</View>
|
|
|
|
{/* QR button */}
|
|
<Pressable
|
|
onPress={openScanner}
|
|
style={({ pressed }) => ({
|
|
width: 48, alignItems: 'center', justifyContent: 'center',
|
|
backgroundColor: '#21262d', borderWidth: 1, borderColor: '#30363d',
|
|
borderRadius: 12, opacity: pressed ? 0.7 : 1,
|
|
})}
|
|
>
|
|
<Text style={{ fontSize: 22 }}>▦</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Status text */}
|
|
{nodeOk === true && (
|
|
<Text style={{ color: '#3fb950', fontSize: 12, marginTop: -4 }}>
|
|
✓ Node connected
|
|
</Text>
|
|
)}
|
|
{nodeOk === false && (
|
|
<Text style={{ color: '#f85149', fontSize: 12, marginTop: -4 }}>
|
|
✗ Cannot reach node — check URL and that the node is running
|
|
</Text>
|
|
)}
|
|
|
|
{/* Buttons */}
|
|
<View style={{ gap: 10, marginTop: 4 }}>
|
|
<Button size="lg" onPress={() => router.push('/(auth)/create')}>
|
|
Create New Account
|
|
</Button>
|
|
<Button variant="outline" size="lg" onPress={() => router.push('/(auth)/import')}>
|
|
Import Existing Key
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
);
|
|
}
|