chore: initial commit for v0.0.1
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
This commit is contained in:
127
client-app/app/(app)/_layout.tsx
Normal file
127
client-app/app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Main app tab layout.
|
||||
* Redirects to welcome if no key found.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Tabs, router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useBalance } from '@/hooks/useBalance';
|
||||
import { useContacts } from '@/hooks/useContacts';
|
||||
import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
|
||||
const C_ACCENT = '#7db5ff';
|
||||
const C_MUTED = '#98a7c2';
|
||||
const C_BG = '#111a2b';
|
||||
const C_BORDER = '#1c2840';
|
||||
|
||||
export default function AppLayout() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const requests = useStore(s => s.requests);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useBalance();
|
||||
useContacts();
|
||||
useWellKnownContracts(); // auto-discover canonical system contracts from node
|
||||
|
||||
// Arm the WS client with this user's Ed25519 keypair. The client signs the
|
||||
// server's auth nonce on every (re)connect so scoped subscriptions
|
||||
// (addr:<my_pub>, inbox:<my_x25519>) are accepted. Without this the
|
||||
// server would still accept global topic subs but reject scoped ones.
|
||||
useEffect(() => {
|
||||
const ws = getWSClient();
|
||||
if (keyFile) {
|
||||
ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
|
||||
} else {
|
||||
ws.setAuthCreds(null);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyFile === null) {
|
||||
const t = setTimeout(() => {
|
||||
if (!useStore.getState().keyFile) router.replace('/');
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
// Tab bar layout math:
|
||||
// icon (22) + gap (4) + label (~13) = ~39px of content
|
||||
// We add a 12px visual margin above, and pad the bottom by the larger of
|
||||
// the platform safe-area inset or 10px so the bar never sits flush on the
|
||||
// home indicator.
|
||||
const BAR_CONTENT_HEIGHT = 52;
|
||||
const bottomPad = Math.max(insets.bottom, 10);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: C_ACCENT,
|
||||
tabBarInactiveTintColor: C_MUTED,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
marginTop: 2,
|
||||
},
|
||||
tabBarStyle: {
|
||||
backgroundColor: C_BG,
|
||||
borderTopColor: C_BORDER,
|
||||
borderTopWidth: 1,
|
||||
height: BAR_CONTENT_HEIGHT + bottomPad,
|
||||
paddingTop: 8,
|
||||
paddingBottom: bottomPad,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="chats"
|
||||
options={{
|
||||
tabBarLabel: 'Чаты',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
name={focused ? 'chatbubbles' : 'chatbubbles-outline'}
|
||||
size={22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
tabBarBadge: requests.length > 0 ? requests.length : undefined,
|
||||
tabBarBadgeStyle: { backgroundColor: C_ACCENT, fontSize: 10 },
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="wallet"
|
||||
options={{
|
||||
tabBarLabel: 'Кошелёк',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
name={focused ? 'wallet' : 'wallet-outline'}
|
||||
size={22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
tabBarLabel: 'Настройки',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
name={focused ? 'settings' : 'settings-outline'}
|
||||
size={22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{/* Non-tab screens — hidden from tab bar */}
|
||||
<Tabs.Screen name="requests" options={{ href: null }} />
|
||||
<Tabs.Screen name="new-contact" options={{ href: null }} />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
413
client-app/app/(app)/chats/[id].tsx
Normal file
413
client-app/app/(app)/chats/[id].tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Chat view — DChain messenger.
|
||||
* Safe-area aware header/input, smooth scroll, proper E2E indicators,
|
||||
* responsive send button with press feedback.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View, Text, FlatList, TextInput, TouchableOpacity, Pressable,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
|
||||
} from 'react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useMessages } from '@/hooks/useMessages';
|
||||
import { encryptMessage } from '@/lib/crypto';
|
||||
import { sendEnvelope } from '@/lib/api';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||
import { formatTime, randomId } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import type { Message } from '@/lib/types';
|
||||
|
||||
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||
const C = {
|
||||
bg: '#0b1220',
|
||||
surface: '#111a2b',
|
||||
surface2:'#162035',
|
||||
surface3:'#1a2640',
|
||||
line: '#1c2840',
|
||||
text: '#e6edf9',
|
||||
muted: '#98a7c2',
|
||||
accent: '#7db5ff',
|
||||
ok: '#41c98a',
|
||||
warn: '#f0b35a',
|
||||
err: '#ff7a87',
|
||||
} as const;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function shortAddr(a: string, n = 5): string {
|
||||
if (!a) return '—';
|
||||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||
}
|
||||
|
||||
/** Group messages by calendar day for day-separator labels. */
|
||||
function dateBucket(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const now = new Date();
|
||||
const yday = new Date(); yday.setDate(now.getDate() - 1);
|
||||
const same = (a: Date, b: Date) =>
|
||||
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
if (same(d, now)) return 'Сегодня';
|
||||
if (same(d, yday)) return 'Вчера';
|
||||
return d.toLocaleDateString('ru', { day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
// A row in the FlatList: either a message or a date separator we inject.
|
||||
type Row =
|
||||
| { kind: 'msg'; msg: Message }
|
||||
| { kind: 'sep'; id: string; label: string };
|
||||
|
||||
function buildRows(msgs: Message[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
let lastBucket = '';
|
||||
for (const m of msgs) {
|
||||
const b = dateBucket(m.timestamp);
|
||||
if (b !== lastBucket) {
|
||||
rows.push({ kind: 'sep', id: `sep_${b}_${m.id}`, label: b });
|
||||
lastBucket = b;
|
||||
}
|
||||
rows.push({ kind: 'msg', msg: m });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Screen ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { id: contactAddress } = useLocalSearchParams<{ id: string }>();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const messages = useStore(s => s.messages);
|
||||
const setMsgs = useStore(s => s.setMessages);
|
||||
const appendMsg = useStore(s => s.appendMessage);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const contact = contacts.find(c => c.address === contactAddress);
|
||||
const chatMsgs = messages[contactAddress ?? ''] ?? [];
|
||||
const listRef = useRef<FlatList>(null);
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [peerTyping, setPeerTyping] = useState(false);
|
||||
|
||||
// Poll relay inbox for messages from this contact
|
||||
useMessages(contact?.x25519Pub ?? '');
|
||||
|
||||
// Subscribe to typing indicators sent to us. Clears after 3 seconds of
|
||||
// silence so the "typing…" bubble disappears naturally when the peer
|
||||
// stops. sendTyping on our side happens per-keystroke (throttled below).
|
||||
useEffect(() => {
|
||||
if (!keyFile?.x25519_pub) return;
|
||||
const ws = getWSClient();
|
||||
let clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => {
|
||||
if (frame.event !== 'typing') return;
|
||||
const d = frame.data as { from?: string } | undefined;
|
||||
// Only show typing for the contact currently open in this view.
|
||||
if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return;
|
||||
setPeerTyping(true);
|
||||
if (clearTimer) clearTimeout(clearTimer);
|
||||
clearTimer = setTimeout(() => setPeerTyping(false), 3_000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
off();
|
||||
if (clearTimer) clearTimeout(clearTimer);
|
||||
};
|
||||
}, [keyFile?.x25519_pub, contact?.x25519Pub]);
|
||||
|
||||
// Throttled sendTyping: fire on every keystroke but no more than every 2s.
|
||||
const lastSentTyping = useRef(0);
|
||||
const handleTextChange = useCallback((t: string) => {
|
||||
setText(t);
|
||||
if (!contact?.x25519Pub || !t.trim()) return;
|
||||
const now = Date.now();
|
||||
if (now - lastSentTyping.current < 2_000) return;
|
||||
lastSentTyping.current = now;
|
||||
getWSClient().sendTyping(contact.x25519Pub);
|
||||
}, [contact?.x25519Pub]);
|
||||
|
||||
// Load cached messages on mount
|
||||
useEffect(() => {
|
||||
if (contactAddress) {
|
||||
loadMessages(contactAddress).then(cached => {
|
||||
setMsgs(contactAddress, cached as Message[]);
|
||||
});
|
||||
}
|
||||
}, [contactAddress]);
|
||||
|
||||
const displayName = contact?.username
|
||||
? `@${contact.username}`
|
||||
: contact?.alias ?? shortAddr(contactAddress ?? '', 6);
|
||||
|
||||
const canSend = !!text.trim() && !sending && !!contact?.x25519Pub;
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (!text.trim() || !keyFile || !contact) return;
|
||||
if (!contact.x25519Pub) {
|
||||
Alert.alert(
|
||||
'Ключ ещё не опубликован',
|
||||
'Контакт пока не опубликовал ключ шифрования. Попробуйте позже.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
text.trim(),
|
||||
keyFile.x25519_priv,
|
||||
contact.x25519Pub,
|
||||
);
|
||||
await sendEnvelope({
|
||||
sender_pub: keyFile.x25519_pub,
|
||||
recipient_pub: contact.x25519Pub,
|
||||
nonce,
|
||||
ciphertext,
|
||||
});
|
||||
const msg: Message = {
|
||||
id: randomId(),
|
||||
from: keyFile.x25519_pub,
|
||||
text: text.trim(),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
mine: true,
|
||||
};
|
||||
appendMsg(contact.address, msg);
|
||||
await appendMessage(contact.address, msg);
|
||||
setText('');
|
||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 50);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Ошибка отправки', e.message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [text, keyFile, contact]);
|
||||
|
||||
const rows = buildRows(chatMsgs);
|
||||
|
||||
const renderRow = ({ item }: { item: Row }) => {
|
||||
if (item.kind === 'sep') {
|
||||
return (
|
||||
<View style={{ alignItems: 'center', marginVertical: 12 }}>
|
||||
<View style={{
|
||||
backgroundColor: C.surface2, borderRadius: 999,
|
||||
paddingHorizontal: 12, paddingVertical: 4,
|
||||
}}>
|
||||
<Text style={{ color: C.muted, fontSize: 11, fontWeight: '500' }}>{item.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const m = item.msg;
|
||||
return (
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: m.mine ? 'flex-end' : 'flex-start',
|
||||
paddingHorizontal: 12,
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
{!m.mine && (
|
||||
<View style={{ marginRight: 8, marginBottom: 4, flexShrink: 0 }}>
|
||||
<Avatar name={displayName} size="sm" />
|
||||
</View>
|
||||
)}
|
||||
<View style={{
|
||||
maxWidth: '78%',
|
||||
backgroundColor: m.mine ? C.accent : C.surface,
|
||||
borderRadius: 18,
|
||||
borderTopRightRadius: m.mine ? 6 : 18,
|
||||
borderTopLeftRadius: m.mine ? 18 : 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 9, paddingBottom: 7,
|
||||
borderWidth: m.mine ? 0 : 1,
|
||||
borderColor: C.line,
|
||||
}}>
|
||||
<Text style={{
|
||||
color: m.mine ? C.bg : C.text,
|
||||
fontSize: 15, lineHeight: 21,
|
||||
}}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end',
|
||||
marginTop: 3, gap: 4,
|
||||
}}>
|
||||
<Text style={{
|
||||
color: m.mine ? 'rgba(11,18,32,0.65)' : C.muted,
|
||||
fontSize: 10,
|
||||
}}>
|
||||
{formatTime(m.timestamp)}
|
||||
</Text>
|
||||
{m.mine && (
|
||||
<Ionicons
|
||||
name="checkmark-done"
|
||||
size={12}
|
||||
color="rgba(11,18,32,0.65)"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: C.bg }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: insets.top + 8,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1, borderBottomColor: C.line,
|
||||
backgroundColor: C.surface,
|
||||
}}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.6}
|
||||
style={{ padding: 8, marginRight: 4 }}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={C.accent} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ marginRight: 10, position: 'relative' }}>
|
||||
<Avatar name={displayName} size="sm" />
|
||||
{contact?.x25519Pub && (
|
||||
<View style={{
|
||||
position: 'absolute', right: -2, bottom: -2,
|
||||
width: 12, height: 12, borderRadius: 6,
|
||||
backgroundColor: C.ok,
|
||||
borderWidth: 2, borderColor: C.surface,
|
||||
}} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
||||
{peerTyping ? (
|
||||
<>
|
||||
<Ionicons name="ellipsis-horizontal" size={12} color={C.accent} />
|
||||
<Text style={{ color: C.accent, fontSize: 11, fontWeight: '500' }}>печатает…</Text>
|
||||
</>
|
||||
) : contact?.x25519Pub ? (
|
||||
<>
|
||||
<Ionicons name="lock-closed" size={10} color={C.ok} />
|
||||
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '500' }}>E2E шифрование</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="time-outline" size={10} color={C.warn} />
|
||||
<Text style={{ color: C.warn, fontSize: 11 }}>Ожидание ключа шифрования</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.6}
|
||||
style={{ padding: 8 }}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="ellipsis-vertical" size={18} color={C.muted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* ── Messages ── */}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
|
||||
renderItem={renderRow}
|
||||
contentContainerStyle={{ paddingTop: 14, paddingBottom: 10, flexGrow: 1 }}
|
||||
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32, gap: 14 }}>
|
||||
<View style={{
|
||||
width: 76, height: 76, borderRadius: 38,
|
||||
backgroundColor: 'rgba(125,181,255,0.08)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Ionicons name="lock-closed-outline" size={34} color={C.accent} />
|
||||
</View>
|
||||
<Text style={{ color: C.text, fontSize: 15, fontWeight: '600', textAlign: 'center' }}>
|
||||
Начните разговор
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||
Сообщения зашифрованы end-to-end.{'\n'}Только вы и {displayName} их прочитаете.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
{/* ── Input bar ── */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'flex-end', gap: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: 8,
|
||||
paddingBottom: Math.max(insets.bottom, 8),
|
||||
borderTopWidth: 1, borderTopColor: C.line,
|
||||
backgroundColor: C.surface,
|
||||
}}>
|
||||
<View style={{
|
||||
flex: 1,
|
||||
backgroundColor: C.surface2,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1, borderColor: C.line,
|
||||
paddingHorizontal: 14, paddingVertical: 8,
|
||||
minHeight: 42, maxHeight: 140,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={handleTextChange}
|
||||
placeholder="Сообщение…"
|
||||
placeholderTextColor={C.muted}
|
||||
multiline
|
||||
maxLength={2000}
|
||||
style={{
|
||||
color: C.text, fontSize: 15, lineHeight: 21,
|
||||
// iOS needs explicit padding:0 to avoid extra vertical space
|
||||
paddingTop: 0, paddingBottom: 0,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={send}
|
||||
disabled={!canSend}
|
||||
style={({ pressed }) => ({
|
||||
width: 42, height: 42, borderRadius: 21,
|
||||
backgroundColor: canSend ? C.accent : C.surface2,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
opacity: pressed && canSend ? 0.7 : 1,
|
||||
transform: [{ scale: pressed && canSend ? 0.95 : 1 }],
|
||||
})}
|
||||
>
|
||||
{sending
|
||||
? <ActivityIndicator color={C.bg} size="small" />
|
||||
: <Ionicons
|
||||
name="send"
|
||||
size={18}
|
||||
color={canSend ? C.bg : C.muted}
|
||||
style={{ marginLeft: 2 }} // visual centre of the paper-plane glyph
|
||||
/>
|
||||
}
|
||||
</Pressable>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
7
client-app/app/(app)/chats/_layout.tsx
Normal file
7
client-app/app/(app)/chats/_layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ChatsLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
);
|
||||
}
|
||||
337
client-app/app/(app)/chats/index.tsx
Normal file
337
client-app/app/(app)/chats/index.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Chat list — DChain messenger.
|
||||
* Safe-area aware, Ionicons, polished empty states, responsive press feedback.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useEffect, useState } from 'react';
|
||||
import { View, Text, FlatList, Pressable, TouchableOpacity } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { formatTime, formatAmount } from '@/lib/utils';
|
||||
import type { Contact } from '@/lib/types';
|
||||
|
||||
const MIN_FEE = 5000;
|
||||
|
||||
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||
const C = {
|
||||
bg: '#0b1220',
|
||||
surface: '#111a2b',
|
||||
surface2: '#162035',
|
||||
surface3: '#1a2640',
|
||||
line: '#1c2840',
|
||||
text: '#e6edf9',
|
||||
muted: '#98a7c2',
|
||||
accent: '#7db5ff',
|
||||
ok: '#41c98a',
|
||||
warn: '#f0b35a',
|
||||
err: '#ff7a87',
|
||||
} as const;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Truncate a message preview without breaking words too awkwardly. */
|
||||
function previewText(s: string, max = 60): string {
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max).trimEnd() + '…';
|
||||
}
|
||||
|
||||
/** Short address helper matching the rest of the app. */
|
||||
function shortAddr(a: string, n = 6): string {
|
||||
if (!a) return '—';
|
||||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||||
}
|
||||
|
||||
export default function ChatsScreen() {
|
||||
const contacts = useStore(s => s.contacts);
|
||||
const messages = useStore(s => s.messages);
|
||||
const requests = useStore(s => s.requests);
|
||||
const balance = useStore(s => s.balance);
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Real-time transport indicator (green dot when WS is live, yellow when
|
||||
// using HTTP polling fallback).
|
||||
const [wsLive, setWsLive] = useState(false);
|
||||
useEffect(() => {
|
||||
const ws = getWSClient();
|
||||
setWsLive(ws.isConnected());
|
||||
return ws.onConnectionChange(ok => setWsLive(ok));
|
||||
}, []);
|
||||
|
||||
const hasBalance = balance >= MIN_FEE;
|
||||
|
||||
const displayName = (c: Contact) =>
|
||||
c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address, 5);
|
||||
|
||||
const lastMsg = (c: Contact) => {
|
||||
const msgs = messages[c.address];
|
||||
return msgs?.length ? msgs[msgs.length - 1] : null;
|
||||
};
|
||||
|
||||
// Sort contacts: most recent activity first.
|
||||
const sortedContacts = useMemo(() => {
|
||||
const withTime = contacts.map(c => {
|
||||
const last = lastMsg(c);
|
||||
return {
|
||||
contact: c,
|
||||
sortKey: last ? last.timestamp : (c.addedAt / 1000),
|
||||
};
|
||||
});
|
||||
return withTime
|
||||
.sort((a, b) => b.sortKey - a.sortKey)
|
||||
.map(x => x.contact);
|
||||
}, [contacts, messages]);
|
||||
|
||||
const renderItem = useCallback(({ item: c, index }: { item: Contact; index: number }) => {
|
||||
const last = lastMsg(c);
|
||||
const name = displayName(c);
|
||||
const hasKey = !!c.x25519Pub;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => router.push(`/(app)/chats/${c.address}`)}
|
||||
android_ripple={{ color: C.surface2 }}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 14, paddingVertical: 12,
|
||||
borderTopWidth: index === 0 ? 0 : 1, borderTopColor: C.line,
|
||||
backgroundColor: pressed ? C.surface2 : 'transparent',
|
||||
})}
|
||||
>
|
||||
{/* Avatar with E2E status pip */}
|
||||
<View style={{ position: 'relative', marginRight: 12 }}>
|
||||
<Avatar name={name} size="md" />
|
||||
{hasKey && (
|
||||
<View style={{
|
||||
position: 'absolute', right: -2, bottom: -2,
|
||||
width: 14, height: 14, borderRadius: 7,
|
||||
backgroundColor: C.ok,
|
||||
borderWidth: 2, borderColor: C.bg,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Ionicons name="lock-closed" size={7} color="#0b1220" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Text column */}
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text
|
||||
style={{ color: C.text, fontWeight: '600', fontSize: 15, flex: 1 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
{last && (
|
||||
<Text style={{ color: C.muted, fontSize: 11, marginLeft: 8 }}>
|
||||
{formatTime(last.timestamp)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 2 }}>
|
||||
{last?.mine && (
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={13}
|
||||
color={C.muted}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={{ color: C.muted, fontSize: 13, flex: 1 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{last ? previewText(last.text) : (hasKey ? 'Напишите первое сообщение' : 'Ожидание публикации ключа…')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}, [messages, lastMsg]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: insets.top + 10,
|
||||
paddingBottom: 14,
|
||||
borderBottomWidth: 1, borderBottomColor: C.line,
|
||||
}}>
|
||||
<View>
|
||||
<Text style={{ color: C.text, fontSize: 22, fontWeight: '700', letterSpacing: -0.3 }}>
|
||||
Сообщения
|
||||
</Text>
|
||||
{contacts.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 2 }}>
|
||||
<Text style={{ color: C.muted, fontSize: 12 }}>
|
||||
{contacts.length} {contacts.length === 1 ? 'контакт' : contacts.length < 5 ? 'контакта' : 'контактов'}
|
||||
{' · '}
|
||||
<Text style={{ color: C.ok }}>E2E</Text>
|
||||
{' · '}
|
||||
</Text>
|
||||
<View style={{
|
||||
width: 6, height: 6, borderRadius: 3,
|
||||
backgroundColor: wsLive ? C.ok : C.warn,
|
||||
}} />
|
||||
<Text style={{ color: wsLive ? C.ok : C.warn, fontSize: 11 }}>
|
||||
{wsLive ? 'live' : 'polling'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||
{/* Incoming requests chip */}
|
||||
{requests.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/(app)/requests')}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||
backgroundColor: 'rgba(125,181,255,0.14)', borderRadius: 999,
|
||||
paddingHorizontal: 12, paddingVertical: 7,
|
||||
borderWidth: 1, borderColor: 'rgba(125,181,255,0.25)',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="mail-unread-outline" size={14} color={C.accent} />
|
||||
<Text style={{ color: C.accent, fontSize: 12, fontWeight: '600' }}>
|
||||
{requests.length}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Add contact button */}
|
||||
<TouchableOpacity
|
||||
onPress={() => hasBalance ? router.push('/(app)/new-contact') : router.push('/(app)/wallet')}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
width: 38, height: 38, borderRadius: 19,
|
||||
backgroundColor: hasBalance ? C.accent : C.surface2,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={hasBalance ? 'add-outline' : 'lock-closed-outline'}
|
||||
size={20}
|
||||
color={hasBalance ? C.bg : C.muted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ── No balance gate (no contacts) ── */}
|
||||
{!hasBalance && contacts.length === 0 && (
|
||||
<View style={{
|
||||
margin: 16, padding: 18,
|
||||
backgroundColor: 'rgba(125,181,255,0.07)',
|
||||
borderRadius: 14,
|
||||
borderWidth: 1, borderColor: 'rgba(125,181,255,0.15)',
|
||||
}}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<View style={{
|
||||
width: 32, height: 32, borderRadius: 16,
|
||||
backgroundColor: 'rgba(125,181,255,0.18)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Ionicons name="diamond-outline" size={16} color={C.accent} />
|
||||
</View>
|
||||
<Text style={{ color: C.text, fontWeight: '700', fontSize: 15 }}>
|
||||
Пополните баланс
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 20, marginBottom: 14 }}>
|
||||
Отправка запроса контакта стоит{' '}
|
||||
<Text style={{ color: C.text, fontWeight: '600' }}>{formatAmount(MIN_FEE)}</Text>
|
||||
{' '}— антиспам-сбор идёт напрямую получателю.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/(app)/wallet')}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
paddingVertical: 11, borderRadius: 10,
|
||||
backgroundColor: C.accent,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="wallet-outline" size={16} color={C.bg} />
|
||||
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 13 }}>Перейти в кошелёк</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Low balance warning (has contacts) ── */}
|
||||
{!hasBalance && contacts.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/(app)/wallet')}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||
marginHorizontal: 16, marginTop: 12,
|
||||
backgroundColor: 'rgba(255,122,135,0.08)',
|
||||
borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10,
|
||||
borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="warning-outline" size={16} color={C.err} />
|
||||
<Text style={{ color: C.err, fontSize: 13, flex: 1 }}>
|
||||
Недостаточно токенов для добавления контакта
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={14} color={C.err} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* ── Empty state (has balance, no contacts) ── */}
|
||||
{contacts.length === 0 && hasBalance && (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
|
||||
<View style={{
|
||||
width: 88, height: 88, borderRadius: 44,
|
||||
backgroundColor: C.surface,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 18,
|
||||
}}>
|
||||
<Ionicons name="chatbubbles-outline" size={40} color={C.accent} />
|
||||
</View>
|
||||
<Text style={{ color: C.text, fontSize: 19, fontWeight: '700', textAlign: 'center', marginBottom: 8 }}>
|
||||
Нет диалогов
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 14, textAlign: 'center', lineHeight: 22, marginBottom: 20 }}>
|
||||
Добавьте контакт по адресу или{' '}
|
||||
<Text style={{ color: C.accent, fontWeight: '600' }}>@username</Text>
|
||||
{' '}для начала зашифрованной переписки.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/(app)/new-contact')}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||
paddingHorizontal: 22, paddingVertical: 12, borderRadius: 12,
|
||||
backgroundColor: C.accent,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="person-add-outline" size={16} color={C.bg} />
|
||||
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 14 }}>Добавить контакт</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Chat list ── */}
|
||||
{contacts.length > 0 && (
|
||||
<FlatList
|
||||
data={sortedContacts}
|
||||
keyExtractor={c => c.address}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
332
client-app/app/(app)/new-contact.tsx
Normal file
332
client-app/app/(app)/new-contact.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Add New Contact — DChain explorer design style.
|
||||
* Sends CONTACT_REQUEST on-chain with correct amount/fee fields.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, Alert, TouchableOpacity, TextInput,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getIdentity, buildContactRequestTx, submitTx } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { formatAmount } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
const C = {
|
||||
bg: '#0b1220',
|
||||
surface: '#111a2b',
|
||||
surface2:'#162035',
|
||||
line: '#1c2840',
|
||||
text: '#e6edf9',
|
||||
muted: '#98a7c2',
|
||||
accent: '#7db5ff',
|
||||
ok: '#41c98a',
|
||||
warn: '#f0b35a',
|
||||
err: '#ff7a87',
|
||||
} as const;
|
||||
|
||||
const MIN_CONTACT_FEE = 5000;
|
||||
|
||||
const FEE_OPTIONS = [
|
||||
{ label: '5 000 µT', value: 5_000, note: 'минимум' },
|
||||
{ label: '10 000 µT', value: 10_000, note: 'стандарт' },
|
||||
{ label: '50 000 µT', value: 50_000, note: 'приоритет' },
|
||||
];
|
||||
|
||||
interface Resolved {
|
||||
address: string;
|
||||
nickname?: string;
|
||||
x25519?: string;
|
||||
}
|
||||
|
||||
export default function NewContactScreen() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const settings = useStore(s => s.settings);
|
||||
const balance = useStore(s => s.balance);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [intro, setIntro] = useState('');
|
||||
const [fee, setFee] = useState(MIN_CONTACT_FEE);
|
||||
const [resolved, setResolved] = useState<Resolved | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function search() {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setSearching(true);
|
||||
setResolved(null);
|
||||
setError(null);
|
||||
try {
|
||||
let address = q;
|
||||
|
||||
// @username lookup via registry contract
|
||||
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
|
||||
const name = q.replace('@', '');
|
||||
const { resolveUsername } = await import('@/lib/api');
|
||||
const addr = await resolveUsername(settings.contractId, name);
|
||||
if (!addr) {
|
||||
setError(`@${name} не зарегистрирован в сети.`);
|
||||
return;
|
||||
}
|
||||
address = addr;
|
||||
}
|
||||
|
||||
// Fetch identity to get nickname and x25519 key
|
||||
const identity = await getIdentity(address);
|
||||
setResolved({
|
||||
address: identity?.pub_key ?? address,
|
||||
nickname: identity?.nickname || undefined,
|
||||
x25519: identity?.x25519_pub || undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequest() {
|
||||
if (!resolved || !keyFile) return;
|
||||
if (balance < fee + 1000) {
|
||||
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(fee + 1000)} (fee + комиссия сети).`);
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tx = buildContactRequestTx({
|
||||
from: keyFile.pub_key,
|
||||
to: resolved.address,
|
||||
contactFee: fee,
|
||||
intro: intro.trim() || undefined,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
Alert.alert(
|
||||
'Запрос отправлен',
|
||||
`Контакту ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)} отправлен запрос.`,
|
||||
[{ text: 'OK', onPress: () => router.back() }],
|
||||
);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = resolved
|
||||
? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: C.bg }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: insets.top + 8,
|
||||
paddingBottom: Math.max(insets.bottom, 32),
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 24, marginLeft: -8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.6}
|
||||
style={{ padding: 8 }}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={C.accent} />
|
||||
</TouchableOpacity>
|
||||
<Text style={{ color: C.text, fontSize: 20, fontWeight: '700' }}>Добавить контакт</Text>
|
||||
</View>
|
||||
|
||||
{/* Search */}
|
||||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Адрес или @username
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 16 }}>
|
||||
<View style={{
|
||||
flex: 1, backgroundColor: C.surface, borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 2,
|
||||
}}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={t => { setQuery(t); setError(null); }}
|
||||
onSubmitEditing={search}
|
||||
placeholder="@username или 64-символьный hex"
|
||||
placeholderTextColor={C.muted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{ color: C.text, fontSize: 14, height: 44 }}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={search}
|
||||
disabled={searching || !query.trim()}
|
||||
style={{
|
||||
paddingHorizontal: 16, borderRadius: 10,
|
||||
backgroundColor: searching || !query.trim() ? C.surface2 : C.accent,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: searching || !query.trim() ? C.muted : '#0b1220', fontWeight: '700', fontSize: 14 }}>
|
||||
{searching ? '…' : 'Найти'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(255,122,135,0.08)', borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 16,
|
||||
}}>
|
||||
<Text style={{ color: C.err, fontSize: 13 }}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Resolved contact card */}
|
||||
{resolved && (
|
||||
<View style={{
|
||||
backgroundColor: C.surface, borderRadius: 12,
|
||||
padding: 14, marginBottom: 20,
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<Avatar name={displayName ?? resolved.address} size="md" />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
{resolved.nickname && (
|
||||
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }}>
|
||||
@{resolved.nickname}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
|
||||
{resolved.address}
|
||||
</Text>
|
||||
{resolved.x25519 && (
|
||||
<Text style={{ color: C.ok, fontSize: 11, marginTop: 2 }}>
|
||||
✓ Зашифрованные сообщения поддерживаются
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={{
|
||||
paddingHorizontal: 8, paddingVertical: 3,
|
||||
backgroundColor: 'rgba(65,201,138,0.12)', borderRadius: 999,
|
||||
}}>
|
||||
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Найден</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Intro + fee (shown after search succeeds) */}
|
||||
{resolved && (
|
||||
<>
|
||||
{/* Intro */}
|
||||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Сообщение (необязательно)
|
||||
</Text>
|
||||
<View style={{
|
||||
backgroundColor: C.surface, borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 8, marginBottom: 20,
|
||||
}}>
|
||||
<TextInput
|
||||
value={intro}
|
||||
onChangeText={setIntro}
|
||||
placeholder="Привет, хочу добавить тебя в контакты…"
|
||||
placeholderTextColor={C.muted}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
maxLength={280}
|
||||
style={{
|
||||
color: C.text, fontSize: 14, lineHeight: 20,
|
||||
minHeight: 72, textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: C.muted, fontSize: 11, textAlign: 'right', marginTop: 4 }}>
|
||||
{intro.length}/280
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Fee selector */}
|
||||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Антиспам-сбор (отправляется контакту)
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 8 }}>
|
||||
{FEE_OPTIONS.map(opt => (
|
||||
<TouchableOpacity
|
||||
key={opt.value}
|
||||
onPress={() => setFee(opt.value)}
|
||||
style={{
|
||||
flex: 1, borderRadius: 10, paddingVertical: 10, alignItems: 'center',
|
||||
backgroundColor: fee === opt.value
|
||||
? 'rgba(125,181,255,0.15)' : C.surface,
|
||||
borderWidth: fee === opt.value ? 1 : 0,
|
||||
borderColor: fee === opt.value ? C.accent : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: fee === opt.value ? C.accent : C.text,
|
||||
fontWeight: '600', fontSize: 13,
|
||||
}}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 11, marginTop: 2 }}>{opt.note}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Balance info */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||
backgroundColor: C.surface, borderRadius: 8,
|
||||
paddingHorizontal: 12, paddingVertical: 9, marginBottom: 20,
|
||||
}}>
|
||||
<Text style={{ color: C.muted, fontSize: 13 }}>Ваш баланс:</Text>
|
||||
<Text style={{ color: balance >= fee + 1000 ? C.text : C.err, fontSize: 13, fontWeight: '600' }}>
|
||||
{formatAmount(balance)}
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 13, marginLeft: 'auto' as any }}>
|
||||
Итого: {formatAmount(fee + 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Send button */}
|
||||
<TouchableOpacity
|
||||
onPress={sendRequest}
|
||||
disabled={sending || balance < fee + 1000}
|
||||
style={{
|
||||
paddingVertical: 15, borderRadius: 10, alignItems: 'center',
|
||||
backgroundColor: (sending || balance < fee + 1000) ? C.surface2 : C.accent,
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: (sending || balance < fee + 1000) ? C.muted : '#0b1220',
|
||||
fontWeight: '700', fontSize: 15,
|
||||
}}>
|
||||
{sending ? 'Отправка…' : 'Отправить запрос'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hint when no search yet */}
|
||||
{!resolved && !error && (
|
||||
<View style={{
|
||||
backgroundColor: C.surface, borderRadius: 12,
|
||||
padding: 16, marginTop: 8, alignItems: 'center',
|
||||
}}>
|
||||
<Text style={{ color: C.muted, fontSize: 14, textAlign: 'center', lineHeight: 21 }}>
|
||||
Введите @username или вставьте 64-символьный hex-адрес пользователя DChain.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
236
client-app/app/(app)/requests.tsx
Normal file
236
client-app/app/(app)/requests.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Contact requests screen — DChain explorer design style.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, FlatList, Alert, TouchableOpacity } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { buildAcceptContactTx, submitTx, getIdentity } from '@/lib/api';
|
||||
import { saveContact } from '@/lib/storage';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { relativeTime } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import type { ContactRequest } from '@/lib/types';
|
||||
|
||||
const C = {
|
||||
bg: '#0b1220',
|
||||
surface: '#111a2b',
|
||||
surface2:'#162035',
|
||||
line: '#1c2840',
|
||||
text: '#e6edf9',
|
||||
muted: '#98a7c2',
|
||||
accent: '#7db5ff',
|
||||
ok: '#41c98a',
|
||||
warn: '#f0b35a',
|
||||
err: '#ff7a87',
|
||||
} as const;
|
||||
|
||||
export default function RequestsScreen() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const requests = useStore(s => s.requests);
|
||||
const setRequests = useStore(s => s.setRequests);
|
||||
const upsertContact = useStore(s => s.upsertContact);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [accepting, setAccepting] = useState<string | null>(null);
|
||||
|
||||
async function accept(req: ContactRequest) {
|
||||
if (!keyFile) return;
|
||||
setAccepting(req.txHash);
|
||||
try {
|
||||
// Fetch requester's identity to get their x25519 key for messaging
|
||||
const identity = await getIdentity(req.from);
|
||||
const x25519Pub = identity?.x25519_pub ?? '';
|
||||
|
||||
const tx = buildAcceptContactTx({
|
||||
from: keyFile.pub_key,
|
||||
to: req.from,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
|
||||
// Save contact with x25519 key (empty if they haven't registered one)
|
||||
const contact = {
|
||||
address: req.from,
|
||||
x25519Pub,
|
||||
username: req.username,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
upsertContact(contact);
|
||||
await saveContact(contact);
|
||||
|
||||
setRequests(requests.filter(r => r.txHash !== req.txHash));
|
||||
Alert.alert('Принято', `${req.username ? '@' + req.username : shortAddr(req.from)} добавлен в контакты.`);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Ошибка', e.message);
|
||||
} finally {
|
||||
setAccepting(null);
|
||||
}
|
||||
}
|
||||
|
||||
function decline(req: ContactRequest) {
|
||||
Alert.alert(
|
||||
'Отклонить запрос',
|
||||
`Отклонить запрос от ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
|
||||
[
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{
|
||||
text: 'Отклонить',
|
||||
style: 'destructive',
|
||||
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||||
{/* Header */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: insets.top + 8, paddingBottom: 12,
|
||||
borderBottomWidth: 1, borderBottomColor: C.line,
|
||||
}}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.6}
|
||||
style={{ padding: 8 }}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={C.accent} />
|
||||
</TouchableOpacity>
|
||||
<Text style={{ color: C.text, fontSize: 18, fontWeight: '700', flex: 1 }}>
|
||||
Запросы контактов
|
||||
</Text>
|
||||
{requests.length > 0 && (
|
||||
<View style={{
|
||||
backgroundColor: C.accent, borderRadius: 999,
|
||||
paddingHorizontal: 10, paddingVertical: 3,
|
||||
minWidth: 24, alignItems: 'center',
|
||||
}}>
|
||||
<Text style={{ color: C.bg, fontSize: 12, fontWeight: '700' }}>
|
||||
{requests.length}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
|
||||
<View style={{
|
||||
width: 80, height: 80, borderRadius: 40,
|
||||
backgroundColor: C.surface,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<Ionicons name="mail-outline" size={36} color={C.muted} />
|
||||
</View>
|
||||
<Text style={{ color: C.text, fontSize: 17, fontWeight: '600', marginBottom: 6 }}>
|
||||
Нет входящих запросов
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||
Когда кто-то пришлёт вам запрос в контакты, он появится здесь.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={requests}
|
||||
keyExtractor={r => r.txHash}
|
||||
contentContainerStyle={{ padding: 14, paddingBottom: 24, gap: 12 }}
|
||||
renderItem={({ item: req }) => (
|
||||
<RequestCard
|
||||
req={req}
|
||||
isAccepting={accepting === req.txHash}
|
||||
onAccept={() => accept(req)}
|
||||
onDecline={() => decline(req)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestCard({
|
||||
req, isAccepting, onAccept, onDecline,
|
||||
}: {
|
||||
req: ContactRequest;
|
||||
isAccepting: boolean;
|
||||
onAccept: () => void;
|
||||
onDecline: () => void;
|
||||
}) {
|
||||
const displayName = req.username ? `@${req.username}` : shortAddr(req.from);
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor: C.surface, borderRadius: 12, padding: 14 }}>
|
||||
{/* Sender info */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<Avatar name={displayName} size="md" />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
|
||||
{req.from}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: C.muted, fontSize: 12 }}>{relativeTime(req.timestamp)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Intro message */}
|
||||
{!!req.intro && (
|
||||
<View style={{
|
||||
backgroundColor: C.surface2, borderRadius: 8,
|
||||
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 12,
|
||||
}}>
|
||||
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 4 }}>Приветствие</Text>
|
||||
<Text style={{ color: C.text, fontSize: 13, lineHeight: 19 }}>{req.intro}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<View style={{ height: 1, backgroundColor: C.line, marginBottom: 12 }} />
|
||||
|
||||
{/* Actions */}
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={onAccept}
|
||||
disabled={isAccepting}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
flex: 1, paddingVertical: 11, borderRadius: 9,
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
backgroundColor: isAccepting ? C.surface2 : C.ok,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isAccepting ? 'hourglass-outline' : 'checkmark-outline'}
|
||||
size={16}
|
||||
color={isAccepting ? C.muted : C.bg}
|
||||
/>
|
||||
<Text style={{ color: isAccepting ? C.muted : C.bg, fontWeight: '700', fontSize: 14 }}>
|
||||
{isAccepting ? 'Принятие…' : 'Принять'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={onDecline}
|
||||
disabled={isAccepting}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
flex: 1, paddingVertical: 11, borderRadius: 9,
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
backgroundColor: 'rgba(255,122,135,0.1)',
|
||||
borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close-outline" size={16} color={C.err} />
|
||||
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Отклонить</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
732
client-app/app/(app)/settings.tsx
Normal file
732
client-app/app/(app)/settings.tsx
Normal file
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* Settings screen — DChain explorer style, inline styles, Russian locale.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, TextInput, Alert, TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { saveSettings, deleteKeyFile } from '@/lib/storage';
|
||||
import {
|
||||
setNodeUrl, getNetStats, resolveUsername, reverseResolve,
|
||||
buildCallContractTx, submitTx,
|
||||
USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
|
||||
humanizeTxError,
|
||||
} from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { formatAmount } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||
const C = {
|
||||
bg: '#0b1220',
|
||||
surface: '#111a2b',
|
||||
surface2:'#162035',
|
||||
surface3:'#1a2640',
|
||||
line: '#1c2840',
|
||||
text: '#e6edf9',
|
||||
muted: '#98a7c2',
|
||||
accent: '#7db5ff',
|
||||
ok: '#41c98a',
|
||||
warn: '#f0b35a',
|
||||
err: '#ff7a87',
|
||||
} as const;
|
||||
|
||||
// ─── Reusable sub-components ─────────────────────────────────────────────────
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<Text style={{
|
||||
color: C.muted, fontSize: 11, letterSpacing: 1,
|
||||
textTransform: 'uppercase', marginBottom: 8,
|
||||
}}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children, style }: { children: React.ReactNode; style?: object }) {
|
||||
return (
|
||||
<View style={{
|
||||
backgroundColor: C.surface, borderRadius: 12,
|
||||
overflow: 'hidden', marginBottom: 20, ...style,
|
||||
}}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CardRow({
|
||||
icon, label, value, first,
|
||||
}: {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
value?: string;
|
||||
first?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
paddingHorizontal: 14, paddingVertical: 12,
|
||||
borderTopWidth: first ? 0 : 1, borderTopColor: C.line,
|
||||
}}>
|
||||
<View style={{
|
||||
width: 34, height: 34, borderRadius: 17,
|
||||
backgroundColor: C.surface2,
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<Ionicons name={icon} size={16} color={C.muted} />
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ color: C.muted, fontSize: 12 }}>{label}</Text>
|
||||
{value !== undefined && (
|
||||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '500', marginTop: 1 }} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
label, value, onChangeText, placeholder, keyboardType, autoCapitalize, autoCorrect,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
placeholder?: string;
|
||||
keyboardType?: any;
|
||||
autoCapitalize?: any;
|
||||
autoCorrect?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ paddingHorizontal: 14, paddingVertical: 10 }}>
|
||||
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 6 }}>{label}</Text>
|
||||
<View style={{
|
||||
backgroundColor: C.surface2, borderRadius: 8,
|
||||
paddingHorizontal: 12, paddingVertical: 8,
|
||||
borderWidth: 1, borderColor: C.line,
|
||||
}}>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={C.muted}
|
||||
keyboardType={keyboardType}
|
||||
autoCapitalize={autoCapitalize ?? 'none'}
|
||||
autoCorrect={autoCorrect ?? false}
|
||||
style={{ color: C.text, fontSize: 14, height: 36 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Screen ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const settings = useStore(s => s.settings);
|
||||
const setSettings = useStore(s => s.setSettings);
|
||||
const username = useStore(s => s.username);
|
||||
const setUsername = useStore(s => s.setUsername);
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const balance = useStore(s => s.balance);
|
||||
|
||||
const [nodeUrl, setNodeUrlLocal] = useState(settings.nodeUrl);
|
||||
const [contractId, setContractId] = useState(settings.contractId);
|
||||
const [nodeStatus, setNodeStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||
const [peerCount, setPeerCount] = useState<number | null>(null);
|
||||
const [blockCount, setBlockCount] = useState<number | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Username registration state
|
||||
const [nameInput, setNameInput] = useState('');
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
|
||||
// Sanitize: lowercase, only a-z 0-9 _ -, max 32 (contract limit)
|
||||
function onNameInputChange(v: string) {
|
||||
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
|
||||
setNameInput(cleaned);
|
||||
setNameError(null);
|
||||
}
|
||||
|
||||
async function registerUsername() {
|
||||
if (!keyFile) return;
|
||||
const name = nameInput.trim();
|
||||
// Mirror blockchain/native_username.go validateName so the UI gives
|
||||
// immediate feedback without a round trip to the chain.
|
||||
if (name.length < MIN_USERNAME_LENGTH) {
|
||||
setNameError(`Минимум ${MIN_USERNAME_LENGTH} символа`);
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z]/.test(name)) {
|
||||
setNameError('Имя должно начинаться с буквы a-z');
|
||||
return;
|
||||
}
|
||||
if (!settings.contractId) {
|
||||
setNameError('Не задан ID контракта реестра в настройках ноды.');
|
||||
return;
|
||||
}
|
||||
const fee = USERNAME_REGISTRATION_FEE;
|
||||
// Reserve for: registry fee (burned) + MIN_CALL_FEE (to validator)
|
||||
// + small gas headroom (native contract is cheap, but gas is pre-charged).
|
||||
const GAS_HEADROOM = 2_000;
|
||||
const total = fee + 1000 + GAS_HEADROOM;
|
||||
if (balance < total) {
|
||||
setNameError(`Нужно ${formatAmount(total)} (с запасом на газ), доступно ${formatAmount(balance)}.`);
|
||||
return;
|
||||
}
|
||||
// Check if name is already taken
|
||||
try {
|
||||
const existing = await resolveUsername(settings.contractId, name);
|
||||
if (existing) {
|
||||
setNameError(`@${name} уже зарегистрировано.`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore — lookup failure is OK, contract will reject on duplicate
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Купить @' + name + '?',
|
||||
`Стоимость: ${formatAmount(fee)} + комиссия ${formatAmount(1000)}.\nИмя привязывается к вашему адресу навсегда (до release).`,
|
||||
[
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{
|
||||
text: 'Купить', onPress: async () => {
|
||||
setRegistering(true);
|
||||
setNameError(null);
|
||||
try {
|
||||
const tx = buildCallContractTx({
|
||||
from: keyFile.pub_key,
|
||||
contractId: settings.contractId,
|
||||
method: 'register',
|
||||
args: [name],
|
||||
// Attach the registration fee in tx.amount — the contract
|
||||
// requires exactly this much and burns it. Visible in the
|
||||
// explorer so the user sees the real cost.
|
||||
amount: USERNAME_REGISTRATION_FEE,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
Alert.alert(
|
||||
'Отправлено',
|
||||
`Транзакция покупки @${name} принята. Имя появится в профиле через несколько секунд.`,
|
||||
);
|
||||
setNameInput('');
|
||||
// Poll every 2s for up to 20s until the address ↔ name binding is visible.
|
||||
let attempts = 0;
|
||||
const iv = setInterval(async () => {
|
||||
attempts++;
|
||||
const got = keyFile
|
||||
? await reverseResolve(settings.contractId, keyFile.pub_key)
|
||||
: null;
|
||||
if (got) {
|
||||
setUsername(got);
|
||||
clearInterval(iv);
|
||||
} else if (attempts >= 10) {
|
||||
clearInterval(iv);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e: any) {
|
||||
setNameError(humanizeTxError(e));
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Flat fee — same for every name that passes validation.
|
||||
const nameFee = USERNAME_REGISTRATION_FEE;
|
||||
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
|
||||
|
||||
useEffect(() => { checkNode(); }, []);
|
||||
|
||||
// Pick up auto-discovered contract IDs (useWellKnownContracts updates the
|
||||
// store; reflect it into the local TextInput state so the UI stays consistent).
|
||||
useEffect(() => {
|
||||
setContractId(settings.contractId);
|
||||
}, [settings.contractId]);
|
||||
|
||||
// When the registry contract becomes known (either via manual save or
|
||||
// auto-discovery), look up the user's registered username reactively.
|
||||
// Sets username unconditionally — a null result CLEARS the cached name,
|
||||
// which matters when the user switches nodes / chains: a name on the
|
||||
// previous chain should no longer show when connected to a chain where
|
||||
// the same pubkey isn't registered.
|
||||
useEffect(() => {
|
||||
if (!settings.contractId || !keyFile) {
|
||||
setUsername(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
|
||||
setUsername(name);
|
||||
})();
|
||||
}, [settings.contractId, keyFile, setUsername]);
|
||||
|
||||
async function checkNode() {
|
||||
setNodeStatus('checking');
|
||||
try {
|
||||
const stats = await getNetStats();
|
||||
setNodeStatus('ok');
|
||||
setPeerCount(stats.peer_count);
|
||||
setBlockCount(stats.total_blocks);
|
||||
if (settings.contractId && keyFile) {
|
||||
// Address → username: must use reverseResolve, not resolveUsername
|
||||
// (resolveUsername goes username → address).
|
||||
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
|
||||
if (name) setUsername(name);
|
||||
}
|
||||
} catch {
|
||||
setNodeStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNode() {
|
||||
const url = nodeUrl.trim().replace(/\/$/, '');
|
||||
setNodeUrl(url);
|
||||
const next = { nodeUrl: url, contractId: contractId.trim() };
|
||||
setSettings(next);
|
||||
await saveSettings(next);
|
||||
Alert.alert('Сохранено', 'Настройки ноды обновлены.');
|
||||
checkNode();
|
||||
}
|
||||
|
||||
async function exportKey() {
|
||||
if (!keyFile) return;
|
||||
try {
|
||||
const json = JSON.stringify(keyFile, null, 2);
|
||||
const path = FileSystem.cacheDirectory + 'dchain_key.json';
|
||||
await FileSystem.writeAsStringAsync(path, json);
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(path, {
|
||||
mimeType: 'application/json',
|
||||
dialogTitle: 'Экспорт ключа DChain',
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
Alert.alert('Ошибка экспорта', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAddress() {
|
||||
if (!keyFile) return;
|
||||
await Clipboard.setStringAsync(keyFile.pub_key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
Alert.alert(
|
||||
'Удалить аккаунт',
|
||||
'Ключ будет удалён с устройства. Убедитесь, что у вас есть резервная копия!',
|
||||
[
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{
|
||||
text: 'Удалить',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await deleteKeyFile();
|
||||
setKeyFile(null);
|
||||
router.replace('/');
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const statusColor = nodeStatus === 'ok' ? C.ok : nodeStatus === 'error' ? C.err : C.warn;
|
||||
const statusLabel = nodeStatus === 'ok' ? 'Подключена' : nodeStatus === 'error' ? 'Недоступна' : 'Проверка…';
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: C.bg }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: insets.top + 12,
|
||||
paddingBottom: Math.max(insets.bottom, 24) + 20,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: C.text, fontSize: 22, fontWeight: '700', marginBottom: 24 }}>
|
||||
Настройки
|
||||
</Text>
|
||||
|
||||
{/* ── Профиль ── */}
|
||||
<SectionLabel>Профиль</SectionLabel>
|
||||
<Card>
|
||||
{/* Avatar row */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 14,
|
||||
paddingHorizontal: 14, paddingVertical: 14,
|
||||
}}>
|
||||
<Avatar
|
||||
name={username ? `@${username}` : (keyFile?.pub_key ?? '?')}
|
||||
size="md"
|
||||
/>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
{username ? (
|
||||
<Text style={{ color: C.text, fontWeight: '700', fontSize: 16 }}>@{username}</Text>
|
||||
) : (
|
||||
<Text style={{ color: C.muted, fontSize: 13 }}>Имя не зарегистрировано</Text>
|
||||
)}
|
||||
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
|
||||
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Copy address */}
|
||||
<View style={{ paddingHorizontal: 14, paddingBottom: 12, borderTopWidth: 1, borderTopColor: C.line, paddingTop: 10 }}>
|
||||
<TouchableOpacity
|
||||
onPress={copyAddress}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
paddingVertical: 10, borderRadius: 8,
|
||||
backgroundColor: copied ? 'rgba(65,201,138,0.12)' : C.surface2,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={copied ? 'checkmark-outline' : 'copy-outline'}
|
||||
size={15}
|
||||
color={copied ? C.ok : C.muted}
|
||||
/>
|
||||
<Text style={{ color: copied ? C.ok : C.muted, fontSize: 13, fontWeight: '600' }}>
|
||||
{copied ? 'Скопировано' : 'Скопировать адрес'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* ── Имя пользователя ── */}
|
||||
<SectionLabel>Имя пользователя</SectionLabel>
|
||||
<Card>
|
||||
{username ? (
|
||||
// Already registered
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
paddingHorizontal: 14, paddingVertical: 14,
|
||||
}}>
|
||||
<View style={{
|
||||
width: 34, height: 34, borderRadius: 17,
|
||||
backgroundColor: 'rgba(65,201,138,0.12)',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<Ionicons name="at-outline" size={16} color={C.ok} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: C.text, fontSize: 15, fontWeight: '700' }}>@{username}</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 12, marginTop: 2 }}>Привязано к вашему адресу</Text>
|
||||
</View>
|
||||
<View style={{
|
||||
paddingHorizontal: 10, paddingVertical: 4,
|
||||
borderRadius: 999, backgroundColor: 'rgba(65,201,138,0.12)',
|
||||
}}>
|
||||
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Активно</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
// Register new
|
||||
<>
|
||||
<View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 6 }}>
|
||||
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600', marginBottom: 3 }}>
|
||||
Купить никнейм
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 12, lineHeight: 17 }}>
|
||||
Короткие имена дороже. Оплата идёт в казну контракта реестра.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Input */}
|
||||
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
backgroundColor: C.surface2, borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderWidth: 1, borderColor: nameError ? C.err : C.line,
|
||||
}}>
|
||||
<Text style={{ color: C.muted, fontSize: 15, marginRight: 4 }}>@</Text>
|
||||
<TextInput
|
||||
value={nameInput}
|
||||
onChangeText={onNameInputChange}
|
||||
placeholder="alice"
|
||||
placeholderTextColor={C.muted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={64}
|
||||
style={{ color: C.text, fontSize: 15, height: 40, flex: 1 }}
|
||||
/>
|
||||
{nameInput.length > 0 && (
|
||||
<Text style={{ color: C.muted, fontSize: 11 }}>{nameInput.length}</Text>
|
||||
)}
|
||||
</View>
|
||||
{nameError && (
|
||||
<Text style={{ color: C.err, fontSize: 12, marginTop: 6 }}>⚠ {nameError}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Fee breakdown + rules */}
|
||||
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
|
||||
<View style={{
|
||||
backgroundColor: C.surface2, borderRadius: 8,
|
||||
paddingHorizontal: 12, paddingVertical: 10,
|
||||
}}>
|
||||
{/* Primary cost line */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Ionicons name="flame-outline" size={14} color={C.warn} />
|
||||
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Плата за ник (сгорает)</Text>
|
||||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>
|
||||
{formatAmount(nameFee)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<Ionicons name="cube-outline" size={14} color={C.muted} />
|
||||
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Комиссия сети (валидатору)</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 13 }}>
|
||||
{formatAmount(1000)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Total */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||
paddingTop: 6,
|
||||
borderTopWidth: 1, borderTopColor: C.line,
|
||||
}}>
|
||||
<Text style={{ color: C.text, fontSize: 12, fontWeight: '600', flex: 1 }}>Итого</Text>
|
||||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '700' }}>
|
||||
{formatAmount(nameFee + 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Rules */}
|
||||
<Text style={{ color: C.muted, fontSize: 11, lineHeight: 17, marginTop: 8 }}>
|
||||
Минимум {MIN_USERNAME_LENGTH} символа, только{' '}
|
||||
<Text style={{ color: C.text }}>a-z 0-9 _ -</Text>
|
||||
, первый символ — буква.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Register button */}
|
||||
<View style={{ paddingHorizontal: 14, paddingVertical: 12 }}>
|
||||
<TouchableOpacity
|
||||
onPress={registerUsername}
|
||||
disabled={registering || !nameIsValid || !settings.contractId}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
paddingVertical: 12, borderRadius: 8,
|
||||
backgroundColor: (registering || !nameIsValid || !settings.contractId)
|
||||
? C.surface2 : C.accent,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={registering ? 'hourglass-outline' : 'at-outline'}
|
||||
size={16}
|
||||
color={(registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg}
|
||||
/>
|
||||
<Text style={{
|
||||
color: (registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg,
|
||||
fontWeight: '700', fontSize: 14,
|
||||
}}>
|
||||
{registering ? 'Покупка…' : 'Купить никнейм'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{!settings.contractId && (
|
||||
<Text style={{ color: C.warn, fontSize: 11, marginTop: 6, textAlign: 'center' }}>
|
||||
Укажите ID контракта реестра в настройках ноды ниже
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Нода ── */}
|
||||
<SectionLabel>Нода</SectionLabel>
|
||||
<Card>
|
||||
{/* Connection status */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
paddingHorizontal: 14, paddingVertical: 12,
|
||||
}}>
|
||||
<View style={{
|
||||
width: 34, height: 34, borderRadius: 17,
|
||||
backgroundColor: `${statusColor}15`,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Ionicons
|
||||
name={nodeStatus === 'ok' ? 'cloud-done-outline' : nodeStatus === 'error' ? 'cloud-offline-outline' : 'cloud-outline'}
|
||||
size={16}
|
||||
color={statusColor}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Подключение</Text>
|
||||
<Text style={{ color: statusColor, fontSize: 12 }}>{statusLabel}</Text>
|
||||
</View>
|
||||
{nodeStatus === 'ok' && (
|
||||
<View style={{ alignItems: 'flex-end', gap: 2 }}>
|
||||
{peerCount !== null && (
|
||||
<Text style={{ color: C.muted, fontSize: 11 }}>
|
||||
<Text style={{ color: C.text, fontWeight: '600' }}>{peerCount}</Text> пиров
|
||||
</Text>
|
||||
)}
|
||||
{blockCount !== null && (
|
||||
<Text style={{ color: C.muted, fontSize: 11 }}>
|
||||
<Text style={{ color: C.text, fontWeight: '600' }}>{blockCount.toLocaleString()}</Text> блоков
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Node URL input */}
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: C.line }}>
|
||||
<FieldInput
|
||||
label="URL ноды"
|
||||
value={nodeUrl}
|
||||
onChangeText={setNodeUrlLocal}
|
||||
keyboardType="url"
|
||||
placeholder="http://localhost:8080"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Registry contract — auto-detected from node; manual override under advanced */}
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: C.line, paddingHorizontal: 14, paddingVertical: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Ionicons
|
||||
name={settings.contractId ? 'checkmark-circle' : 'help-circle-outline'}
|
||||
size={14}
|
||||
color={settings.contractId ? C.ok : C.warn}
|
||||
/>
|
||||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 0.5 }}>
|
||||
КОНТРАКТ РЕЕСТРА ИМЁН
|
||||
</Text>
|
||||
<Text style={{
|
||||
marginLeft: 'auto' as any, color: settings.contractId ? C.ok : C.warn, fontSize: 11, fontWeight: '600',
|
||||
}}>
|
||||
{settings.contractId ? 'Авто-обнаружен' : 'Не найден'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: C.text, fontSize: 12, fontFamily: 'monospace' }} numberOfLines={1}>
|
||||
{settings.contractId || '—'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowAdvanced(v => !v)}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<Text style={{ color: C.accent, fontSize: 11 }}>
|
||||
{showAdvanced ? '▾ Скрыть ручной ввод' : '▸ Указать вручную (не требуется)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{showAdvanced && (
|
||||
<View style={{
|
||||
marginTop: 8,
|
||||
backgroundColor: C.surface2, borderRadius: 8,
|
||||
paddingHorizontal: 12, paddingVertical: 8,
|
||||
borderWidth: 1, borderColor: C.line,
|
||||
}}>
|
||||
<TextInput
|
||||
value={contractId}
|
||||
onChangeText={setContractId}
|
||||
placeholder="hex contract ID"
|
||||
placeholderTextColor={C.muted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{ color: C.text, fontSize: 13, fontFamily: 'monospace', height: 36 }}
|
||||
/>
|
||||
<Text style={{ color: C.muted, fontSize: 10, marginTop: 4 }}>
|
||||
Оставьте пустым — клиент запросит канонический контракт у ноды.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Save button */}
|
||||
<View style={{ paddingHorizontal: 14, paddingBottom: 12, paddingTop: 4, borderTopWidth: 1, borderTopColor: C.line }}>
|
||||
<TouchableOpacity
|
||||
onPress={saveNode}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
paddingVertical: 11, borderRadius: 8, backgroundColor: C.accent,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="save-outline" size={16} color={C.bg} />
|
||||
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 14 }}>Сохранить и переподключиться</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* ── Безопасность ── */}
|
||||
<SectionLabel>Безопасность</SectionLabel>
|
||||
<Card>
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
paddingHorizontal: 14, paddingVertical: 14,
|
||||
}}>
|
||||
<View style={{
|
||||
width: 34, height: 34, borderRadius: 17,
|
||||
backgroundColor: 'rgba(125,181,255,0.10)',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<Ionicons name="shield-outline" size={16} color={C.accent} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Экспорт ключа</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 12, marginTop: 1 }}>Сохранить приватный ключ как key.json</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={exportKey}
|
||||
style={{
|
||||
paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8,
|
||||
backgroundColor: C.surface2, borderWidth: 1, borderColor: C.line,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>Экспорт</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* ── Опасная зона ── */}
|
||||
<SectionLabel>Опасная зона</SectionLabel>
|
||||
<Card style={{ backgroundColor: 'rgba(255,122,135,0.04)', borderWidth: 1, borderColor: 'rgba(255,122,135,0.20)' }}>
|
||||
<View style={{ paddingHorizontal: 14, paddingVertical: 14 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<Ionicons name="warning-outline" size={16} color={C.err} />
|
||||
<Text style={{ color: C.err, fontSize: 14, fontWeight: '700' }}>Удалить аккаунт</Text>
|
||||
</View>
|
||||
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 19, marginBottom: 12 }}>
|
||||
Удаляет ключ с устройства. Онлайн-идентичность сохраняется, но доступ будет потерян без резервной копии.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={logout}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
paddingVertical: 11, borderRadius: 8,
|
||||
backgroundColor: 'rgba(255,122,135,0.12)',
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={16} color={C.err} />
|
||||
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Удалить с устройства</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
596
client-app/app/(app)/wallet.tsx
Normal file
596
client-app/app/(app)/wallet.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* Wallet screen — DChain explorer style.
|
||||
* Balance block inspired by Tinkoff/Gravity UI reference.
|
||||
* Icons: Ionicons from @expo/vector-icons.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, Modal, TouchableOpacity,
|
||||
Alert, RefreshControl,
|
||||
} from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useBalance } from '@/hooks/useBalance';
|
||||
import { buildTransferTx, submitTx, getTxHistory, getBalance } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { formatAmount, relativeTime } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { TxRecord } from '@/lib/types';
|
||||
|
||||
// ─── Design tokens ────────────────────────────────────────────────────────────
|
||||
const C = {
|
||||
bg: '#0b1220',
|
||||
surface: '#111a2b',
|
||||
surface2:'#162035',
|
||||
surface3:'#1a2640',
|
||||
line: '#1c2840',
|
||||
text: '#e6edf9',
|
||||
muted: '#98a7c2',
|
||||
accent: '#7db5ff',
|
||||
ok: '#41c98a',
|
||||
warn: '#f0b35a',
|
||||
err: '#ff7a87',
|
||||
} as const;
|
||||
|
||||
// ─── TX metadata ──────────────────────────────────────────────────────────────
|
||||
|
||||
const TX_META: Record<string, { label: string; icon: keyof typeof Ionicons.glyphMap; color: string }> = {
|
||||
TRANSFER: { label: 'Перевод', icon: 'paper-plane-outline', color: C.accent },
|
||||
CONTACT_REQUEST: { label: 'Запрос контакта', icon: 'person-add-outline', color: C.ok },
|
||||
ACCEPT_CONTACT: { label: 'Принят контакт', icon: 'person-outline', color: C.ok },
|
||||
BLOCK_CONTACT: { label: 'Блокировка', icon: 'ban-outline', color: C.err },
|
||||
DEPLOY_CONTRACT: { label: 'Деплой контракта',icon: 'document-text-outline', color: C.warn },
|
||||
CALL_CONTRACT: { label: 'Вызов контракта', icon: 'flash-outline', color: C.warn },
|
||||
STAKE: { label: 'Стейкинг', icon: 'lock-closed-outline', color: C.accent},
|
||||
UNSTAKE: { label: 'Вывод стейка', icon: 'lock-open-outline', color: C.muted },
|
||||
REGISTER_KEY: { label: 'Регистрация', icon: 'key-outline', color: C.muted },
|
||||
BLOCK_REWARD: { label: 'Награда', icon: 'diamond-outline', color: C.ok },
|
||||
};
|
||||
|
||||
function txMeta(type: string) {
|
||||
return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline' as any, color: C.muted };
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WalletScreen() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const balance = useStore(s => s.balance);
|
||||
const setBalance = useStore(s => s.setBalance);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useBalance();
|
||||
|
||||
const [txHistory, setTxHistory] = useState<TxRecord[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showSend, setShowSend] = useState(false);
|
||||
const [selectedTx, setSelectedTx] = useState<TxRecord | null>(null);
|
||||
const [toAddress, setToAddress] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [fee, setFee] = useState('1000');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!keyFile) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [hist, bal] = await Promise.all([
|
||||
getTxHistory(keyFile.pub_key),
|
||||
getBalance(keyFile.pub_key),
|
||||
]);
|
||||
setTxHistory(hist);
|
||||
setBalance(bal);
|
||||
} catch {}
|
||||
setRefreshing(false);
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const copyAddress = async () => {
|
||||
if (!keyFile) return;
|
||||
await Clipboard.setStringAsync(keyFile.pub_key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!keyFile) return;
|
||||
const amt = parseInt(amount);
|
||||
const f = parseInt(fee);
|
||||
if (!toAddress.trim() || isNaN(amt) || amt <= 0) {
|
||||
Alert.alert('Неверные данные', 'Введите корректный адрес и сумму.');
|
||||
return;
|
||||
}
|
||||
if (amt + f > balance) {
|
||||
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(amt + f)}, доступно ${formatAmount(balance)}.`);
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const tx = buildTransferTx({ from: keyFile.pub_key, to: toAddress.trim(), amount: amt, fee: f, privKey: keyFile.priv_key });
|
||||
await submitTx(tx);
|
||||
setShowSend(false);
|
||||
setToAddress('');
|
||||
setAmount('');
|
||||
Alert.alert('Отправлено', 'Транзакция принята нодой.');
|
||||
setTimeout(load, 1500);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Ошибка', e.message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||||
<ScrollView
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} tintColor={C.accent} />}
|
||||
contentContainerStyle={{ paddingBottom: 32 }}
|
||||
>
|
||||
{/* ── Balance hero ── */}
|
||||
<BalanceHero
|
||||
balance={balance}
|
||||
address={keyFile?.pub_key ?? ''}
|
||||
copied={copied}
|
||||
topInset={insets.top}
|
||||
onSend={() => setShowSend(true)}
|
||||
onReceive={copyAddress}
|
||||
onRefresh={load}
|
||||
onCopy={copyAddress}
|
||||
/>
|
||||
|
||||
{/* ── Transaction list ── */}
|
||||
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
|
||||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
История транзакций
|
||||
</Text>
|
||||
|
||||
{txHistory.length === 0 ? (
|
||||
<View style={{ backgroundColor: C.surface, borderRadius: 12, paddingVertical: 36, alignItems: 'center' }}>
|
||||
<Ionicons name="receipt-outline" size={32} color={C.muted} style={{ marginBottom: 10 }} />
|
||||
<Text style={{ color: C.muted, fontSize: 14 }}>Нет транзакций</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 12, marginTop: 4 }}>Потяните вниз, чтобы обновить</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden' }}>
|
||||
{/* Table header */}
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: C.line,
|
||||
}}>
|
||||
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, width: 140 }}>
|
||||
ТИП
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, flex: 1 }}>
|
||||
АДРЕС
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 10, fontWeight: '600', letterSpacing: 0.5, width: 84, textAlign: 'right' }}>
|
||||
СУММА
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{txHistory.map((tx, i) => (
|
||||
<TxRow
|
||||
key={tx.hash}
|
||||
tx={tx}
|
||||
myPubKey={keyFile?.pub_key ?? ''}
|
||||
isLast={i === txHistory.length - 1}
|
||||
onPress={() => setSelectedTx(tx)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Send modal */}
|
||||
<Modal visible={showSend} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSend(false)}>
|
||||
<SendSheet
|
||||
balance={balance}
|
||||
toAddress={toAddress} setToAddress={setToAddress}
|
||||
amount={amount} setAmount={setAmount}
|
||||
fee={fee} setFee={setFee}
|
||||
sending={sending}
|
||||
onSend={send}
|
||||
onClose={() => setShowSend(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* TX Detail modal */}
|
||||
<Modal visible={!!selectedTx} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setSelectedTx(null)}>
|
||||
{selectedTx && (
|
||||
<TxDetailSheet tx={selectedTx} myPubKey={keyFile?.pub_key ?? ''} onClose={() => setSelectedTx(null)} />
|
||||
)}
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Balance Hero ─────────────────────────────────────────────────────────────
|
||||
|
||||
function BalanceHero({
|
||||
balance, address, copied, topInset, onSend, onReceive, onRefresh, onCopy,
|
||||
}: {
|
||||
balance: number; address: string; copied: boolean;
|
||||
topInset: number;
|
||||
onSend: () => void; onReceive: () => void;
|
||||
onRefresh: () => void; onCopy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={{
|
||||
backgroundColor: C.surface,
|
||||
paddingTop: topInset + 16, paddingBottom: 28,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomLeftRadius: 20,
|
||||
borderBottomRightRadius: 20,
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
{/* Label */}
|
||||
<Text style={{ color: C.muted, fontSize: 13, marginBottom: 8, letterSpacing: 0.3 }}>
|
||||
Баланс
|
||||
</Text>
|
||||
|
||||
{/* Main balance */}
|
||||
<Text style={{ color: C.text, fontSize: 42, fontWeight: '700', letterSpacing: -1, lineHeight: 50 }}>
|
||||
{formatAmount(balance)}
|
||||
</Text>
|
||||
|
||||
{/* µT sub-label */}
|
||||
<Text style={{ color: C.muted, fontSize: 13, marginTop: 4, marginBottom: 20 }}>
|
||||
{(balance ?? 0).toLocaleString()} µT
|
||||
</Text>
|
||||
|
||||
{/* Address chip */}
|
||||
<TouchableOpacity
|
||||
onPress={onCopy}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 6,
|
||||
backgroundColor: copied ? 'rgba(65,201,138,0.12)' : C.surface2,
|
||||
borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={copied ? 'checkmark-outline' : 'copy-outline'}
|
||||
size={13}
|
||||
color={copied ? C.ok : C.muted}
|
||||
/>
|
||||
<Text style={{ color: copied ? C.ok : C.muted, fontSize: 12, fontFamily: 'monospace' }}>
|
||||
{copied ? 'Скопировано' : (address ? shortAddr(address, 8) : '—')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Action buttons */}
|
||||
<View style={{ flexDirection: 'row', gap: 20 }}>
|
||||
<ActionButton icon="paper-plane-outline" label="Отправить" color={C.accent} onPress={onSend} />
|
||||
<ActionButton icon="arrow-down-circle-outline" label="Получить" color={C.accent} onPress={onReceive} />
|
||||
<ActionButton icon="refresh-outline" label="Обновить" color={C.muted} onPress={onRefresh} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon, label, color, onPress,
|
||||
}: {
|
||||
icon: keyof typeof Ionicons.glyphMap; label: string; color: string; onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={{ alignItems: 'center', gap: 8 }}>
|
||||
<View style={{
|
||||
width: 52, height: 52, borderRadius: 26,
|
||||
backgroundColor: C.surface3,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Ionicons name={icon} size={22} color={color} />
|
||||
</View>
|
||||
<Text style={{ color: C.muted, fontSize: 12 }}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Transaction Row ──────────────────────────────────────────────────────────
|
||||
// Column widths must match the header above:
|
||||
// col1 (type+icon): 140 col2 (address): flex:1 col3 (amount): 84
|
||||
|
||||
// tx types where "from" is always the owner but it's income, not a send
|
||||
const RECEIVED_TYPES = new Set(['BLOCK_REWARD', 'STAKE_REWARD']);
|
||||
|
||||
function TxRow({
|
||||
tx, myPubKey, isLast, onPress,
|
||||
}: {
|
||||
tx: TxRecord; myPubKey: string; isLast: boolean; onPress: () => void;
|
||||
}) {
|
||||
const meta = txMeta(tx.type);
|
||||
const isSynthetic = !tx.from; // block reward / mint
|
||||
const isSent = !isSynthetic && !RECEIVED_TYPES.has(tx.type) && tx.from === myPubKey;
|
||||
const amt = tx.amount ?? 0;
|
||||
const amtText = amt === 0 ? '' : `${isSent ? '−' : '+'}${formatAmount(amt)}`;
|
||||
const amtColor = isSent ? C.err : C.ok;
|
||||
|
||||
// Counterpart label: for synthetic (empty from) rewards → "Сеть",
|
||||
// otherwise show the short address of the other side.
|
||||
const counterpart = isSynthetic
|
||||
? 'Сеть'
|
||||
: isSent
|
||||
? (tx.to ? shortAddr(tx.to, 6) : '—')
|
||||
: shortAddr(tx.from, 6);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.6}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 11,
|
||||
borderBottomWidth: isLast ? 0 : 1,
|
||||
borderBottomColor: C.line,
|
||||
}}
|
||||
>
|
||||
{/* Col 1 — icon + type label, fixed 140px */}
|
||||
<View style={{ width: 140, flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={{
|
||||
width: 28, height: 28, borderRadius: 14,
|
||||
backgroundColor: `${meta.color}1a`,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
marginRight: 8, flexShrink: 0,
|
||||
}}>
|
||||
<Ionicons name={meta.icon} size={14} color={meta.color} />
|
||||
</View>
|
||||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '500', flexShrink: 1 }} numberOfLines={1}>
|
||||
{meta.label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Col 2 — address, flex */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: C.muted, fontSize: 12 }} numberOfLines={1}>
|
||||
{isSent ? `→ ${counterpart}` : `← ${counterpart}`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Col 3 — amount + time, fixed 84px, right-aligned */}
|
||||
<View style={{ width: 84, alignItems: 'flex-end' }}>
|
||||
{!!amtText && (
|
||||
<Text style={{ color: amtColor, fontSize: 12, fontWeight: '600' }} numberOfLines={1}>
|
||||
{amtText}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ color: C.muted, fontSize: 11, marginTop: amtText ? 1 : 0 }} numberOfLines={1}>
|
||||
{relativeTime(tx.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Send Sheet ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SendSheet({
|
||||
balance, toAddress, setToAddress, amount, setAmount, fee, setFee, sending, onSend, onClose,
|
||||
}: {
|
||||
balance: number;
|
||||
toAddress: string; setToAddress: (v: string) => void;
|
||||
amount: string; setAmount: (v: string) => void;
|
||||
fee: string; setFee: (v: string) => void;
|
||||
sending: boolean; onSend: () => void; onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: C.bg, paddingHorizontal: 16, paddingTop: 20 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<Text style={{ color: C.text, fontSize: 20, fontWeight: '700' }}>Отправить</Text>
|
||||
<TouchableOpacity onPress={onClose} style={{ padding: 8, backgroundColor: C.surface2, borderRadius: 8 }}>
|
||||
<Ionicons name="close-outline" size={18} color={C.muted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||
backgroundColor: C.surface, borderRadius: 8,
|
||||
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 20,
|
||||
}}>
|
||||
<Ionicons name="wallet-outline" size={15} color={C.muted} />
|
||||
<Text style={{ color: C.muted, fontSize: 13 }}>Доступно:</Text>
|
||||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>{formatAmount(balance)}</Text>
|
||||
</View>
|
||||
|
||||
<Input label="Адрес получателя" placeholder="64-символьный hex" value={toAddress}
|
||||
onChangeText={setToAddress} autoCapitalize="none" autoCorrect={false} className="mb-4" />
|
||||
<Input label="Сумма (µT)" placeholder="например 100000" value={amount}
|
||||
onChangeText={setAmount} keyboardType="numeric" className="mb-4" />
|
||||
<Input label="Комиссия (µT)" value={fee}
|
||||
onChangeText={setFee} keyboardType="numeric" className="mb-8" />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onSend}
|
||||
disabled={sending}
|
||||
style={{
|
||||
paddingVertical: 15, borderRadius: 10, alignItems: 'center', flexDirection: 'row',
|
||||
justifyContent: 'center', gap: 8,
|
||||
backgroundColor: sending ? C.surface2 : C.accent,
|
||||
}}
|
||||
>
|
||||
{!sending && <Ionicons name="paper-plane-outline" size={18} color="#0b1220" />}
|
||||
<Text style={{ color: sending ? C.muted : '#0b1220', fontWeight: '700', fontSize: 15 }}>
|
||||
{sending ? 'Отправка…' : 'Подтвердить'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TX Detail Sheet ──────────────────────────────────────────────────────────
|
||||
|
||||
function TxDetailSheet({
|
||||
tx, myPubKey, onClose,
|
||||
}: {
|
||||
tx: TxRecord; myPubKey: string; onClose: () => void;
|
||||
}) {
|
||||
const [copiedHash, setCopiedHash] = useState(false);
|
||||
const meta = txMeta(tx.type);
|
||||
const isSent = tx.from === myPubKey;
|
||||
const amtValue = tx.amount ?? 0;
|
||||
|
||||
const copyHash = async () => {
|
||||
await Clipboard.setStringAsync(tx.hash);
|
||||
setCopiedHash(true);
|
||||
setTimeout(() => setCopiedHash(false), 2000);
|
||||
};
|
||||
|
||||
const amtColor = amtValue === 0 ? C.muted : isSent ? C.err : C.ok;
|
||||
const amtSign = amtValue === 0 ? '' : isSent ? '−' : '+';
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||||
{/* Handle */}
|
||||
<View style={{ alignItems: 'center', paddingTop: 12, marginBottom: 4 }}>
|
||||
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: C.line }} />
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 8, paddingBottom: 32 }}>
|
||||
{/* Header */}
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<Text style={{ color: C.text, fontSize: 17, fontWeight: '700' }}>Транзакция</Text>
|
||||
<TouchableOpacity onPress={onClose} style={{ padding: 8, backgroundColor: C.surface2, borderRadius: 8 }}>
|
||||
<Ionicons name="close-outline" size={18} color={C.muted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Hero */}
|
||||
<View style={{ backgroundColor: C.surface, borderRadius: 12, padding: 20, alignItems: 'center', marginBottom: 16 }}>
|
||||
<View style={{
|
||||
width: 56, height: 56, borderRadius: 28,
|
||||
backgroundColor: `${meta.color}18`,
|
||||
alignItems: 'center', justifyContent: 'center', marginBottom: 12,
|
||||
}}>
|
||||
<Ionicons name={meta.icon} size={26} color={meta.color} />
|
||||
</View>
|
||||
<Text style={{ color: C.text, fontSize: 16, fontWeight: '600', marginBottom: amtValue > 0 ? 8 : 0 }}>
|
||||
{meta.label}
|
||||
</Text>
|
||||
{amtValue > 0 && (
|
||||
<Text style={{ color: amtColor, fontSize: 30, fontWeight: '700', letterSpacing: -0.5 }}>
|
||||
{amtSign}{formatAmount(amtValue)}
|
||||
</Text>
|
||||
)}
|
||||
<View style={{
|
||||
marginTop: 12, paddingHorizontal: 12, paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
backgroundColor: tx.status === 'confirmed' ? 'rgba(65,201,138,0.12)' : 'rgba(240,179,90,0.12)',
|
||||
}}>
|
||||
<Text style={{ color: tx.status === 'confirmed' ? C.ok : C.warn, fontSize: 12, fontWeight: '600' }}>
|
||||
{tx.status === 'confirmed' ? '✓ Подтверждена' : '⏳ В обработке'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Details table */}
|
||||
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 16 }}>
|
||||
<DetailRow icon="code-outline" label="Тип" value={tx.type} mono first />
|
||||
{tx.amount !== undefined && (
|
||||
<DetailRow icon="cash-outline" label="Сумма" value={`${tx.amount.toLocaleString()} µT`} />
|
||||
)}
|
||||
<DetailRow icon="pricetag-outline" label="Комиссия" value={`${(tx.fee ?? 0).toLocaleString()} µT`} />
|
||||
{tx.timestamp > 0 && (
|
||||
<DetailRow icon="time-outline" label="Время" value={new Date(tx.timestamp * 1000).toLocaleString()} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Addresses */}
|
||||
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 16 }}>
|
||||
{/* "From" for synthetic txs (empty tx.from) reads "Сеть" rather than an empty row. */}
|
||||
<DetailRow
|
||||
icon="person-outline"
|
||||
label="От"
|
||||
value={tx.from || 'Сеть (синтетическая tx)'}
|
||||
mono={!!tx.from}
|
||||
truncate={!!tx.from}
|
||||
first
|
||||
/>
|
||||
{tx.to && (
|
||||
<DetailRow icon="arrow-forward-outline" label="Кому" value={tx.to} mono truncate />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* TX Hash */}
|
||||
<View style={{ backgroundColor: C.surface, borderRadius: 12, overflow: 'hidden', marginBottom: 20 }}>
|
||||
<View style={{ paddingHorizontal: 14, paddingVertical: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<Ionicons name="receipt-outline" size={13} color={C.muted} />
|
||||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||
TX ID / Hash
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: C.text, fontFamily: 'monospace', fontSize: 11, lineHeight: 17 }}>
|
||||
{tx.hash}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={copyHash}
|
||||
style={{
|
||||
paddingVertical: 13, borderRadius: 10, alignItems: 'center',
|
||||
flexDirection: 'row', justifyContent: 'center', gap: 8,
|
||||
backgroundColor: copiedHash ? 'rgba(65,201,138,0.12)' : C.surface2,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={copiedHash ? 'checkmark-outline' : 'copy-outline'}
|
||||
size={16}
|
||||
color={copiedHash ? C.ok : C.text}
|
||||
/>
|
||||
<Text style={{ color: copiedHash ? C.ok : C.text, fontWeight: '600', fontSize: 14 }}>
|
||||
{copiedHash ? 'Скопировано' : 'Копировать хеш'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail Row ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DetailRow({
|
||||
icon, label, value, mono, truncate, first,
|
||||
}: {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string; value: string;
|
||||
mono?: boolean; truncate?: boolean; first?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 14, paddingVertical: 11,
|
||||
borderTopWidth: first ? 0 : 1, borderTopColor: C.line,
|
||||
gap: 10,
|
||||
}}>
|
||||
<Ionicons name={icon} size={14} color={C.muted} style={{ width: 16 }} />
|
||||
<Text style={{ color: C.muted, fontSize: 13, width: 72 }}>{label}</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: C.text,
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontFamily: mono ? 'monospace' : undefined,
|
||||
fontSize: mono ? 11 : 13,
|
||||
}}
|
||||
numberOfLines={truncate ? 1 : undefined}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
82
client-app/app/(auth)/create.tsx
Normal file
82
client-app/app/(auth)/create.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Create Account screen.
|
||||
* Generates a new Ed25519 + X25519 keypair and saves it securely.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, Alert } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { generateKeyFile } from '@/lib/crypto';
|
||||
import { saveKeyFile } from '@/lib/storage';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
export default function CreateAccountScreen() {
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const kf = generateKeyFile();
|
||||
await saveKeyFile(kf);
|
||||
setKeyFile(kf);
|
||||
router.replace('/(auth)/created');
|
||||
} catch (e: any) {
|
||||
Alert.alert('Error', e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-background"
|
||||
contentContainerClassName="px-6 pt-16 pb-10"
|
||||
>
|
||||
{/* Header */}
|
||||
<Button variant="ghost" size="sm" onPress={() => router.back()} className="self-start mb-6 -ml-2">
|
||||
← Back
|
||||
</Button>
|
||||
|
||||
<Text className="text-white text-3xl font-bold mb-2">Create Account</Text>
|
||||
<Text className="text-muted text-base mb-8 leading-6">
|
||||
A new identity will be generated on your device.
|
||||
Your private key never leaves this app.
|
||||
</Text>
|
||||
|
||||
{/* Info cards */}
|
||||
<Card className="mb-4 gap-3">
|
||||
<InfoRow icon="🔑" label="Ed25519 signing key" desc="Your blockchain address and tx signing key" />
|
||||
<InfoRow icon="🔒" label="X25519 encryption key" desc="End-to-end encryption for messages" />
|
||||
<InfoRow icon="📱" label="Stored on device" desc="Keys are encrypted in the device secure store" />
|
||||
</Card>
|
||||
|
||||
<Card className="mb-8 border-primary/30 bg-primary/10">
|
||||
<Text className="text-accent text-sm font-semibold mb-1">⚠ Important</Text>
|
||||
<Text className="text-muted text-sm leading-5">
|
||||
After creation, export and backup your key file.
|
||||
If you lose it there is no recovery — the blockchain has no password reset.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Button onPress={handleCreate} loading={loading} size="lg">
|
||||
Generate Keys & Create Account
|
||||
</Button>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, desc }: { icon: string; label: string; desc: string }) {
|
||||
return (
|
||||
<View className="flex-row items-start gap-3">
|
||||
<Text className="text-xl">{icon}</Text>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white text-sm font-semibold">{label}</Text>
|
||||
<Text className="text-muted text-xs mt-0.5">{desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
118
client-app/app/(auth)/created.tsx
Normal file
118
client-app/app/(auth)/created.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Account Created confirmation screen.
|
||||
* Shows address, pubkeys, and export options.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, Alert, Share } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Separator } from '@/components/ui/Separator';
|
||||
|
||||
export default function AccountCreatedScreen() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
if (!keyFile) {
|
||||
router.replace('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
async function copy(value: string, label: string) {
|
||||
await Clipboard.setStringAsync(value);
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
async function exportKey() {
|
||||
try {
|
||||
const json = JSON.stringify(keyFile, null, 2);
|
||||
const path = FileSystem.cacheDirectory + 'dchain_key.json';
|
||||
await FileSystem.writeAsStringAsync(path, json);
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(path, {
|
||||
mimeType: 'application/json',
|
||||
dialogTitle: 'Save your DChain key file',
|
||||
});
|
||||
} else {
|
||||
Alert.alert('Export', 'Sharing not available on this device.');
|
||||
}
|
||||
} catch (e: any) {
|
||||
Alert.alert('Export failed', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-background"
|
||||
contentContainerClassName="px-6 pt-16 pb-10"
|
||||
>
|
||||
{/* Success header */}
|
||||
<View className="items-center mb-8">
|
||||
<View className="w-20 h-20 rounded-full bg-success/20 items-center justify-center mb-4">
|
||||
<Text className="text-4xl">✓</Text>
|
||||
</View>
|
||||
<Text className="text-white text-2xl font-bold">Account Created!</Text>
|
||||
<Text className="text-muted text-sm mt-2 text-center">
|
||||
Your keys have been generated and stored securely.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Address card */}
|
||||
<Card className="mb-4">
|
||||
<Text className="text-muted text-xs uppercase tracking-widest mb-3 font-semibold">
|
||||
Your Address (Ed25519)
|
||||
</Text>
|
||||
<Text className="text-white font-mono text-xs leading-5 mb-3">
|
||||
{keyFile.pub_key}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => copy(keyFile.pub_key, 'address')}
|
||||
>
|
||||
{copied === 'address' ? '✓ Copied' : 'Copy Address'}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* X25519 key */}
|
||||
<Card className="mb-4">
|
||||
<Text className="text-muted text-xs uppercase tracking-widest mb-3 font-semibold">
|
||||
Encryption Key (X25519)
|
||||
</Text>
|
||||
<Text className="text-white font-mono text-xs leading-5 mb-3">
|
||||
{keyFile.x25519_pub}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => copy(keyFile.x25519_pub, 'x25519')}
|
||||
>
|
||||
{copied === 'x25519' ? '✓ Copied' : 'Copy Encryption Key'}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Export warning */}
|
||||
<Card className="mb-8 border-yellow-500/30 bg-yellow-500/10">
|
||||
<Text className="text-yellow-400 text-sm font-semibold mb-2">🔐 Backup your key file</Text>
|
||||
<Text className="text-muted text-xs leading-5 mb-3">
|
||||
Export <Text className="text-white font-mono">dchain_key.json</Text> and store it safely.
|
||||
This file contains your private keys — keep it secret.
|
||||
</Text>
|
||||
<Button variant="outline" onPress={exportKey}>
|
||||
Export key.json
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Button size="lg" onPress={() => router.replace('/(app)/chats')}>
|
||||
Open Messenger
|
||||
</Button>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
301
client-app/app/(auth)/import.tsx
Normal file
301
client-app/app/(auth)/import.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Import Existing Key screen.
|
||||
* Two methods:
|
||||
* 1. Paste JSON directly into a text field
|
||||
* 2. Pick key.json file via document picker
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, TextInput,
|
||||
TouchableOpacity, Alert, Pressable,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { saveKeyFile } from '@/lib/storage';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
|
||||
type Tab = 'paste' | 'file';
|
||||
|
||||
const REQUIRED_FIELDS: (keyof KeyFile)[] = ['pub_key', 'priv_key', 'x25519_pub', 'x25519_priv'];
|
||||
|
||||
function validateKeyFile(raw: string): KeyFile {
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(raw.trim());
|
||||
} catch {
|
||||
throw new Error('Invalid JSON — check that you copied the full key file contents.');
|
||||
}
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!parsed[field] || typeof parsed[field] !== 'string') {
|
||||
throw new Error(`Missing or invalid field: "${field}"`);
|
||||
}
|
||||
if (!/^[0-9a-f]+$/i.test(parsed[field])) {
|
||||
throw new Error(`Field "${field}" must be a hex string.`);
|
||||
}
|
||||
}
|
||||
return parsed as KeyFile;
|
||||
}
|
||||
|
||||
export default function ImportKeyScreen() {
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
|
||||
const [tab, setTab] = useState<Tab>('paste');
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Shared: save validated key and navigate ──────────────────────────────
|
||||
async function applyKey(kf: KeyFile) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await saveKeyFile(kf);
|
||||
setKeyFile(kf);
|
||||
router.replace('/(app)/chats');
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Method 1: paste JSON ─────────────────────────────────────────────────
|
||||
async function handlePasteImport() {
|
||||
setError(null);
|
||||
const text = jsonText.trim();
|
||||
if (!text) {
|
||||
// Try reading clipboard if field is empty
|
||||
const clip = await Clipboard.getStringAsync();
|
||||
if (clip) setJsonText(clip);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const kf = validateKeyFile(text);
|
||||
await applyKey(kf);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Method 2: pick file ──────────────────────────────────────────────────
|
||||
async function pickFile() {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: ['application/json', 'text/plain', '*/*'],
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
if (result.canceled) return;
|
||||
|
||||
const asset = result.assets[0];
|
||||
setFileName(asset.name);
|
||||
|
||||
// Use fetch() — readAsStringAsync is deprecated in newer expo-file-system
|
||||
const response = await fetch(asset.uri);
|
||||
const raw = await response.text();
|
||||
|
||||
const kf = validateKeyFile(raw);
|
||||
await applyKey(kf);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const tabStyle = (t: Tab) => ({
|
||||
flex: 1 as const,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center' as const,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: tab === t ? '#2563eb' : 'transparent',
|
||||
});
|
||||
|
||||
const tabTextStyle = (t: Tab) => ({
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
color: tab === t ? '#fff' : '#8b949e',
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: '#0d1117' }}
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 60, paddingBottom: 40 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
{/* Back */}
|
||||
<Pressable onPress={() => router.back()} style={{ marginBottom: 24, alignSelf: 'flex-start' }}>
|
||||
<Text style={{ color: '#2563eb', fontSize: 15 }}>← Back</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={{ color: '#fff', fontSize: 28, fontWeight: '700', marginBottom: 6 }}>
|
||||
Import Key
|
||||
</Text>
|
||||
<Text style={{ color: '#8b949e', fontSize: 15, lineHeight: 22, marginBottom: 24 }}>
|
||||
Restore your account from an existing{' '}
|
||||
<Text style={{ color: '#fff', fontFamily: 'monospace' }}>key.json</Text>.
|
||||
</Text>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1, borderBottomColor: '#30363d',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<TouchableOpacity style={tabStyle('paste')} onPress={() => setTab('paste')}>
|
||||
<Text style={tabTextStyle('paste')}>📋 Paste JSON</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tabStyle('file')} onPress={() => setTab('file')}>
|
||||
<Text style={tabTextStyle('file')}>📁 Open File</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* ── Paste tab ── */}
|
||||
{tab === 'paste' && (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Text style={{ color: '#8b949e', fontSize: 12, fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
Key JSON
|
||||
</Text>
|
||||
|
||||
<View style={{
|
||||
backgroundColor: '#161b22',
|
||||
borderWidth: 1,
|
||||
borderColor: error ? '#f85149' : jsonText ? '#2563eb' : '#30363d',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
}}>
|
||||
<TextInput
|
||||
value={jsonText}
|
||||
onChangeText={t => { setJsonText(t); setError(null); }}
|
||||
placeholder={'{\n "pub_key": "...",\n "priv_key": "...",\n "x25519_pub": "...",\n "x25519_priv": "..."\n}'}
|
||||
placeholderTextColor="#8b949e"
|
||||
multiline
|
||||
numberOfLines={8}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
minHeight: 160,
|
||||
textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Paste from clipboard shortcut */}
|
||||
{!jsonText && (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
const clip = await Clipboard.getStringAsync();
|
||||
if (clip) { setJsonText(clip); setError(null); }
|
||||
else Alert.alert('Clipboard empty', 'Copy your key JSON first.');
|
||||
}}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||
padding: 12, backgroundColor: '#161b22',
|
||||
borderWidth: 1, borderColor: '#30363d', borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18 }}>📋</Text>
|
||||
<Text style={{ color: '#8b949e', fontSize: 14 }}>Paste from clipboard</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(248,81,73,0.1)',
|
||||
borderWidth: 1, borderColor: 'rgba(248,81,73,0.3)',
|
||||
borderRadius: 10, padding: 12,
|
||||
}}>
|
||||
<Text style={{ color: '#f85149', fontSize: 13, lineHeight: 18 }}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
loading={loading}
|
||||
disabled={!jsonText.trim()}
|
||||
onPress={handlePasteImport}
|
||||
>
|
||||
Import Key
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── File tab ── */}
|
||||
{tab === 'file' && (
|
||||
<View style={{ gap: 12 }}>
|
||||
<TouchableOpacity
|
||||
onPress={pickFile}
|
||||
style={{
|
||||
backgroundColor: '#161b22',
|
||||
borderWidth: 2, borderColor: '#30363d',
|
||||
borderRadius: 16, borderStyle: 'dashed',
|
||||
padding: 32, alignItems: 'center', gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 40 }}>📂</Text>
|
||||
<Text style={{ color: '#fff', fontSize: 16, fontWeight: '600' }}>
|
||||
{fileName ?? 'Choose key.json'}
|
||||
</Text>
|
||||
<Text style={{ color: '#8b949e', fontSize: 13, textAlign: 'center' }}>
|
||||
Tap to browse files
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{fileName && (
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||
backgroundColor: 'rgba(63,185,80,0.1)',
|
||||
borderWidth: 1, borderColor: 'rgba(63,185,80,0.3)',
|
||||
borderRadius: 10, padding: 12,
|
||||
}}>
|
||||
<Text style={{ fontSize: 18 }}>📄</Text>
|
||||
<Text style={{ color: '#3fb950', fontSize: 13, flex: 1 }} numberOfLines={1}>
|
||||
{fileName}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(248,81,73,0.1)',
|
||||
borderWidth: 1, borderColor: 'rgba(248,81,73,0.3)',
|
||||
borderRadius: 10, padding: 12,
|
||||
}}>
|
||||
<Text style={{ color: '#f85149', fontSize: 13, lineHeight: 18 }}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<Text style={{ color: '#8b949e', textAlign: 'center', fontSize: 14 }}>
|
||||
Validating key…
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Format hint */}
|
||||
<View style={{
|
||||
marginTop: 28, padding: 14,
|
||||
backgroundColor: '#161b22',
|
||||
borderWidth: 1, borderColor: '#30363d', borderRadius: 12,
|
||||
}}>
|
||||
<Text style={{ color: '#8b949e', fontSize: 12, fontWeight: '600',
|
||||
marginBottom: 8, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
Expected format
|
||||
</Text>
|
||||
<Text style={{ color: '#8b949e', fontFamily: 'monospace', fontSize: 11, lineHeight: 17 }}>
|
||||
{`{\n "pub_key": "<64 hex chars>",\n "priv_key": "<128 hex chars>",\n "x25519_pub": "<64 hex chars>",\n "x25519_priv": "<64 hex chars>"\n}`}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
40
client-app/app/_layout.tsx
Normal file
40
client-app/app/_layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import '../global.css';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { View } from 'react-native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { loadKeyFile, loadSettings } from '@/lib/storage';
|
||||
import { setNodeUrl } from '@/lib/api';
|
||||
import { useStore } from '@/lib/store';
|
||||
|
||||
export default function RootLayout() {
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const setSettings = useStore(s => s.setSettings);
|
||||
|
||||
// Bootstrap: load key + settings from storage
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [kf, settings] = await Promise.all([loadKeyFile(), loadSettings()]);
|
||||
if (kf) setKeyFile(kf);
|
||||
setSettings(settings);
|
||||
setNodeUrl(settings.nodeUrl);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<View className="flex-1 bg-background">
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#0b1220' },
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
220
client-app/app/index.tsx
Normal file
220
client-app/app/index.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user