@magicred-1/react-native-lxmf 0.2.34 → 0.2.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.plugin.js +8 -1
- package/example/app/(tabs)/_layout.tsx +27 -12
- package/example/app/(tabs)/conversations.tsx +263 -0
- package/example/app/(tabs)/index.tsx +3 -896
- package/example/app/(tabs)/network.tsx +258 -0
- package/example/app/(tabs)/settings.tsx +230 -0
- package/example/app/_layout.tsx +4 -3
- package/example/app/conversation/[address].tsx +329 -0
- package/example/components/ui/icon-symbol.tsx +3 -0
- package/package.json +1 -1
|
@@ -1,898 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
PermissionsAndroid,
|
|
4
|
-
Platform,
|
|
5
|
-
Pressable,
|
|
6
|
-
ScrollView,
|
|
7
|
-
Share,
|
|
8
|
-
StyleSheet,
|
|
9
|
-
Switch,
|
|
10
|
-
Text,
|
|
11
|
-
TextInput,
|
|
12
|
-
View,
|
|
13
|
-
} from 'react-native';
|
|
14
|
-
import * as SecureStore from 'expo-secure-store';
|
|
15
|
-
import { LxmfModule, LxmfNodeMode, type LxmfEvent, type LxmfMessageEvent, useLxmf } from '@magicred-1/react-native-lxmf';
|
|
1
|
+
import { Redirect } from 'expo-router';
|
|
16
2
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// Schema version bumps allow forward-compatible migrations if the FFI changes.
|
|
20
|
-
const IDENTITY_KEY = 'lxmf.identity.v1';
|
|
21
|
-
const IDENTITY_SCHEMA_VERSION = 1;
|
|
22
|
-
type StoredIdentity = {
|
|
23
|
-
version: number;
|
|
24
|
-
identity_hex: string; // 128 hex chars (private key)
|
|
25
|
-
address_hex: string; // 32 hex chars (LXMF address)
|
|
26
|
-
created_at: string; // ISO8601
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
function isValidIdentity(blob: unknown): blob is StoredIdentity {
|
|
30
|
-
if (!blob || typeof blob !== 'object') return false;
|
|
31
|
-
const b = blob as Record<string, unknown>;
|
|
32
|
-
return (
|
|
33
|
-
typeof b.version === 'number' &&
|
|
34
|
-
typeof b.identity_hex === 'string' && /^[0-9a-fA-F]{128}$/.test(b.identity_hex) &&
|
|
35
|
-
typeof b.address_hex === 'string' && /^[0-9a-fA-F]{32}$/.test(b.address_hex) &&
|
|
36
|
-
typeof b.created_at === 'string'
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
41
|
-
|
|
42
|
-
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
43
|
-
|
|
44
|
-
function bytesToBase64(bytes: Uint8Array): string {
|
|
45
|
-
let out = '';
|
|
46
|
-
for (let i = 0; i < bytes.length; i += 3) {
|
|
47
|
-
const b0 = bytes[i] ?? 0, b1 = bytes[i + 1] ?? 0, b2 = bytes[i + 2] ?? 0;
|
|
48
|
-
const t = (b0 << 16) | (b1 << 8) | b2;
|
|
49
|
-
out += B64[(t >> 18) & 0x3f];
|
|
50
|
-
out += B64[(t >> 12) & 0x3f];
|
|
51
|
-
out += i + 1 < bytes.length ? B64[(t >> 6) & 0x3f] : '=';
|
|
52
|
-
out += i + 2 < bytes.length ? B64[t & 0x3f] : '=';
|
|
53
|
-
}
|
|
54
|
-
return out;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function utf8ToBase64(s: string): string {
|
|
58
|
-
if (typeof globalThis.btoa === 'function') return globalThis.btoa(s);
|
|
59
|
-
if (typeof TextEncoder !== 'undefined') return bytesToBase64(new TextEncoder().encode(s));
|
|
60
|
-
return s;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function shortHex(v: string): string {
|
|
64
|
-
if (!v) return '—';
|
|
65
|
-
return v.length <= 12 ? v : `${v.slice(0, 6)}…${v.slice(-6)}`;
|
|
3
|
+
export default function Index() {
|
|
4
|
+
return <Redirect href="/(tabs)/conversations" />;
|
|
66
5
|
}
|
|
67
|
-
|
|
68
|
-
function ts(e: LxmfEvent): number | null {
|
|
69
|
-
const r = e.timestamp ?? e.ts ?? e.time ?? e.epoch;
|
|
70
|
-
if (typeof r === 'number' && Number.isFinite(r)) return r;
|
|
71
|
-
if (typeof r === 'string') { const n = Number(r); return Number.isFinite(n) ? n : null; }
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function fmtTime(e: LxmfEvent): string {
|
|
76
|
-
const t = ts(e);
|
|
77
|
-
if (!t) return 'now';
|
|
78
|
-
return new Date(t > 10_000_000_000 ? t : t * 1000)
|
|
79
|
-
.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function base64ToUtf8(b64: string): string {
|
|
83
|
-
if (!b64) return '';
|
|
84
|
-
try {
|
|
85
|
-
const binary = globalThis.atob(b64);
|
|
86
|
-
const bytes = Uint8Array.from(binary, c => c.codePointAt(0) ?? 0);
|
|
87
|
-
return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
|
88
|
-
} catch {
|
|
89
|
-
return '';
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function evtSummary(e: LxmfEvent): string {
|
|
94
|
-
if (e.type === 'announceReceived') {
|
|
95
|
-
const from = shortHex(String(e.destHash ?? e.address ?? e.source ?? '?'));
|
|
96
|
-
const hops = e.hops ?? e.hopCount;
|
|
97
|
-
return hops === undefined ? `Announce ${from}` : `Announce ${from} (${hops} hop)`;
|
|
98
|
-
}
|
|
99
|
-
if (e.type === 'messageReceived') return `Msg from ${shortHex(String(e.source ?? e.from ?? '?'))}`;
|
|
100
|
-
if (e.type === 'log') return String(e.message ?? e.msg ?? 'log');
|
|
101
|
-
if (e.type === 'error') return String(e.message ?? 'error');
|
|
102
|
-
return e.type;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function evtKey(e: LxmfEvent, prefix = ''): string {
|
|
106
|
-
const t = ts(e) ?? 'na';
|
|
107
|
-
const m = String(e.id ?? e.receipt ?? e.destHash ?? e.source ?? e.message ?? 'ev');
|
|
108
|
-
return `${prefix}${e.type}-${t}-${m}`;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function copyToClipboard(text: string) {
|
|
112
|
-
try {
|
|
113
|
-
await Share.share({ message: text });
|
|
114
|
-
} catch {}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ── Accordion ─────────────────────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
function Accordion({
|
|
120
|
-
title,
|
|
121
|
-
badge,
|
|
122
|
-
defaultOpen = false,
|
|
123
|
-
children,
|
|
124
|
-
}: Readonly<{
|
|
125
|
-
title: string;
|
|
126
|
-
badge?: string | number;
|
|
127
|
-
defaultOpen?: boolean;
|
|
128
|
-
children: React.ReactNode;
|
|
129
|
-
}>) {
|
|
130
|
-
const [open, setOpen] = useState(defaultOpen);
|
|
131
|
-
return (
|
|
132
|
-
<View style={S.accordion}>
|
|
133
|
-
<Pressable
|
|
134
|
-
style={({ pressed }) => [S.accordionHeader, pressed && S.accordionHeaderPressed]}
|
|
135
|
-
onPress={() => setOpen(o => !o)}>
|
|
136
|
-
<Text style={S.accordionChevron}>{open ? '▾' : '▸'}</Text>
|
|
137
|
-
<Text style={S.accordionTitle}>{title}</Text>
|
|
138
|
-
{badge === undefined ? null : (
|
|
139
|
-
<View style={S.accordionBadge}>
|
|
140
|
-
<Text style={S.accordionBadgeText}>{badge}</Text>
|
|
141
|
-
</View>
|
|
142
|
-
)}
|
|
143
|
-
</Pressable>
|
|
144
|
-
{open ? <View style={S.accordionBody}>{children}</View> : null}
|
|
145
|
-
</View>
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// ── Tiny components ──────────────────────────────────────────────────────────
|
|
150
|
-
|
|
151
|
-
function Btn({
|
|
152
|
-
label, onPress, disabled, danger, small,
|
|
153
|
-
}: Readonly<{ label: string; onPress: () => void; disabled?: boolean; danger?: boolean; small?: boolean }>) {
|
|
154
|
-
return (
|
|
155
|
-
<Pressable
|
|
156
|
-
style={({ pressed }) => [
|
|
157
|
-
S.btn, danger && S.btnDanger, disabled && S.btnDisabled, small && S.btnSmall,
|
|
158
|
-
pressed && !disabled && S.btnPressed,
|
|
159
|
-
]}
|
|
160
|
-
onPress={onPress}
|
|
161
|
-
disabled={disabled}>
|
|
162
|
-
<Text style={[S.btnText, small && S.btnTextSmall]}>{label}</Text>
|
|
163
|
-
</Pressable>
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function Row({ label, value, onCopy }: Readonly<{ label: string; value: string; onCopy?: () => void }>) {
|
|
168
|
-
return (
|
|
169
|
-
<View style={S.statRow}>
|
|
170
|
-
<Text style={S.statLabel}>{label}</Text>
|
|
171
|
-
<View style={S.statValueRow}>
|
|
172
|
-
<Text selectable style={S.statValue}>{value}</Text>
|
|
173
|
-
{onCopy ? (
|
|
174
|
-
<Pressable onPress={onCopy} style={S.copyBtn}>
|
|
175
|
-
<Text style={S.copyBtnText}>⎘</Text>
|
|
176
|
-
</Pressable>
|
|
177
|
-
) : null}
|
|
178
|
-
</View>
|
|
179
|
-
</View>
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function Pill({ label, active }: Readonly<{ label: string; active: boolean }>) {
|
|
184
|
-
return (
|
|
185
|
-
<View style={[S.pill, active && S.pillActive]}>
|
|
186
|
-
<Text style={[S.pillText, active && S.pillTextActive]}>{label}</Text>
|
|
187
|
-
</View>
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ── Main screen ──────────────────────────────────────────────────────────────
|
|
192
|
-
|
|
193
|
-
export default function HomeScreen() {
|
|
194
|
-
// Transport state
|
|
195
|
-
const [tcpHost, setTcpHost] = useState('192.168.1.135');
|
|
196
|
-
const [tcpPort, setTcpPort] = useState('4243');
|
|
197
|
-
const [displayName, setDisplayName] = useState('lxmf-mobile');
|
|
198
|
-
const [isBeacon, setIsBeacon] = useState(false);
|
|
199
|
-
const [bleActive, setBleActive] = useState(false);
|
|
200
|
-
const [tcpActive, setTcpActive] = useState(false);
|
|
201
|
-
const [transportMsg, setTransportMsg] = useState('');
|
|
202
|
-
|
|
203
|
-
// Send state
|
|
204
|
-
const [dest, setDest] = useState('');
|
|
205
|
-
const [msgText, setMsgText] = useState('Hello from LXMF');
|
|
206
|
-
const [sendResult, setSendResult] = useState('');
|
|
207
|
-
|
|
208
|
-
const [unpairedRNodes, setUnpairedRNodes] = useState(0);
|
|
209
|
-
const [liveBleCount, setLiveBleCount] = useState(0);
|
|
210
|
-
const [storedMsgs, setStoredMsgs] = useState<any[]>([]);
|
|
211
|
-
|
|
212
|
-
// Identity hydration: read once from secure store on mount. Until hydrated,
|
|
213
|
-
// we pass 'new' so Rust generates a fresh identity (which we'll then persist
|
|
214
|
-
// after start succeeds, see effect below).
|
|
215
|
-
const [storedIdentity, setStoredIdentity] = useState<StoredIdentity | null>(null);
|
|
216
|
-
const [identityHydrated, setIdentityHydrated] = useState(false);
|
|
217
|
-
useEffect(() => {
|
|
218
|
-
let cancelled = false;
|
|
219
|
-
(async () => {
|
|
220
|
-
try {
|
|
221
|
-
const raw = await SecureStore.getItemAsync(IDENTITY_KEY);
|
|
222
|
-
if (cancelled) return;
|
|
223
|
-
if (raw) {
|
|
224
|
-
const parsed = JSON.parse(raw);
|
|
225
|
-
if (isValidIdentity(parsed)) setStoredIdentity(parsed);
|
|
226
|
-
}
|
|
227
|
-
} catch {
|
|
228
|
-
// Corrupt blob or storage error — fall through; generate fresh identity.
|
|
229
|
-
} finally {
|
|
230
|
-
if (!cancelled) setIdentityHydrated(true);
|
|
231
|
-
}
|
|
232
|
-
})();
|
|
233
|
-
return () => { cancelled = true; };
|
|
234
|
-
}, []);
|
|
235
|
-
|
|
236
|
-
const {
|
|
237
|
-
isNativeAvailable, isRunning, status, error, events,
|
|
238
|
-
start, stop, send, broadcast, getStatus, getIdentityHex, fetchMessages,
|
|
239
|
-
bleUnpairedRNodeCount,
|
|
240
|
-
} = useLxmf({
|
|
241
|
-
identityHex: storedIdentity?.identity_hex ?? 'new',
|
|
242
|
-
lxmfAddressHex: storedIdentity?.address_hex ?? 'new',
|
|
243
|
-
logLevel: 3,
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
// Persist identity after node starts (only when identity changes from stored copy).
|
|
247
|
-
useEffect(() => {
|
|
248
|
-
if (!isRunning) return;
|
|
249
|
-
const idHex = getIdentityHex();
|
|
250
|
-
const addrHex = status?.addressHex;
|
|
251
|
-
if (!idHex || idHex.length !== 128) return;
|
|
252
|
-
if (!addrHex || !/^[0-9a-fA-F]{32}$/.test(addrHex)) return;
|
|
253
|
-
if (storedIdentity?.identity_hex === idHex && storedIdentity?.address_hex === addrHex) return;
|
|
254
|
-
const blob: StoredIdentity = {
|
|
255
|
-
version: IDENTITY_SCHEMA_VERSION,
|
|
256
|
-
identity_hex: idHex,
|
|
257
|
-
address_hex: addrHex,
|
|
258
|
-
created_at: new Date().toISOString(),
|
|
259
|
-
};
|
|
260
|
-
SecureStore.setItemAsync(IDENTITY_KEY, JSON.stringify(blob))
|
|
261
|
-
.then(() => setStoredIdentity(blob))
|
|
262
|
-
.catch(() => { /* non-fatal */ });
|
|
263
|
-
}, [isRunning, status?.addressHex, storedIdentity, getIdentityHex]);
|
|
264
|
-
|
|
265
|
-
// Load persisted messages from SQLite whenever node starts.
|
|
266
|
-
useEffect(() => {
|
|
267
|
-
if (isRunning) setStoredMsgs(fetchMessages(50));
|
|
268
|
-
}, [isRunning, fetchMessages]);
|
|
269
|
-
|
|
270
|
-
// ── Derived ───────────────────────────────────────────────────────────────
|
|
271
|
-
|
|
272
|
-
const counts = useMemo(() => {
|
|
273
|
-
let announces = 0, logs = 0, messages = 0, errors = 0;
|
|
274
|
-
for (const e of events) {
|
|
275
|
-
if (e.type === 'announceReceived') announces++;
|
|
276
|
-
if (e.type === 'log') logs++;
|
|
277
|
-
if (e.type === 'messageReceived') messages++;
|
|
278
|
-
if (e.type === 'error') errors++;
|
|
279
|
-
}
|
|
280
|
-
return { announces, logs, messages, errors };
|
|
281
|
-
}, [events]);
|
|
282
|
-
|
|
283
|
-
const announceEvts = useMemo(() => events.filter(e => e.type === 'announceReceived').slice(0, 20), [events]);
|
|
284
|
-
const msgEvts = useMemo(() => events.filter(e => e.type === 'messageReceived').slice(0, 20), [events]);
|
|
285
|
-
const logEvts = useMemo(() => events.filter(e => e.type === 'log').slice(0, 100), [events]);
|
|
286
|
-
|
|
287
|
-
// Deduped peer identity hashes from all announce events (any interface)
|
|
288
|
-
const knownPeerHashes = useMemo(() => {
|
|
289
|
-
const map = new Map<string, { hash: string; name: string; lastSeen: string }>();
|
|
290
|
-
for (const e of events) {
|
|
291
|
-
if (e.type !== 'announceReceived') continue;
|
|
292
|
-
const hash = String(e.destHash ?? e.address ?? '');
|
|
293
|
-
if (!hash) continue;
|
|
294
|
-
if (!map.has(hash)) {
|
|
295
|
-
map.set(hash, { hash, name: e.appData ? String(e.appData) : '', lastSeen: fmtTime(e) });
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return Array.from(map.values());
|
|
299
|
-
}, [events]);
|
|
300
|
-
const allEvts = useMemo(() => events.slice(0, 30), [events]);
|
|
301
|
-
|
|
302
|
-
// ── Actions ───────────────────────────────────────────────────────────────
|
|
303
|
-
|
|
304
|
-
const onStartTcp = useCallback(async () => {
|
|
305
|
-
setTransportMsg('');
|
|
306
|
-
const host = tcpHost.trim();
|
|
307
|
-
const port = Number(tcpPort);
|
|
308
|
-
if (!host) { setTransportMsg('Host required.'); return; }
|
|
309
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) { setTransportMsg('Port 1–65535.'); return; }
|
|
310
|
-
const ok = await start({
|
|
311
|
-
mode: LxmfNodeMode.ReticulumAndBle,
|
|
312
|
-
tcpInterfaces: [{ host, port }],
|
|
313
|
-
displayName: displayName.trim() || 'lxmf-mobile',
|
|
314
|
-
});
|
|
315
|
-
if (ok) {
|
|
316
|
-
setTcpActive(true);
|
|
317
|
-
setBleActive(true);
|
|
318
|
-
}
|
|
319
|
-
}, [tcpHost, tcpPort, displayName, start]);
|
|
320
|
-
|
|
321
|
-
const onStopTcp = useCallback(async () => {
|
|
322
|
-
await stop();
|
|
323
|
-
setTcpActive(false);
|
|
324
|
-
setBleActive(false);
|
|
325
|
-
}, [stop]);
|
|
326
|
-
|
|
327
|
-
const onStartBle = useCallback(async () => {
|
|
328
|
-
setTransportMsg('');
|
|
329
|
-
if (Platform.OS === 'android') {
|
|
330
|
-
const perms = Platform.Version >= 31
|
|
331
|
-
? [
|
|
332
|
-
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
|
|
333
|
-
PermissionsAndroid.PERMISSIONS.BLUETOOTH_ADVERTISE,
|
|
334
|
-
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
|
335
|
-
]
|
|
336
|
-
: [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
|
|
337
|
-
const results = await PermissionsAndroid.requestMultiple(perms);
|
|
338
|
-
if (Object.values(results).some(r => r !== PermissionsAndroid.RESULTS.GRANTED)) {
|
|
339
|
-
setTransportMsg('BLE permissions denied.');
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
const ok = await start({
|
|
344
|
-
mode: LxmfNodeMode.BleOnly,
|
|
345
|
-
displayName: displayName.trim() || 'lxmf-mobile',
|
|
346
|
-
});
|
|
347
|
-
if (!ok) { setTransportMsg('Failed to start BLE node.'); return; }
|
|
348
|
-
setBleActive(true);
|
|
349
|
-
}, [start, displayName]);
|
|
350
|
-
|
|
351
|
-
const onStopBle = useCallback(async () => {
|
|
352
|
-
await stop();
|
|
353
|
-
setBleActive(false);
|
|
354
|
-
setUnpairedRNodes(0);
|
|
355
|
-
}, [stop]);
|
|
356
|
-
|
|
357
|
-
const onBroadcast = useCallback(async () => {
|
|
358
|
-
if (!knownPeerHashes.length) { setSendResult('No known peers.'); return; }
|
|
359
|
-
const dests = knownPeerHashes.map(p => p.hash);
|
|
360
|
-
const r = await broadcast(dests, utf8ToBase64(msgText));
|
|
361
|
-
setSendResult(r >= 0 ? `Broadcast #${r} → ${dests.length} peers` : 'Broadcast failed.');
|
|
362
|
-
}, [knownPeerHashes, msgText, broadcast]);
|
|
363
|
-
|
|
364
|
-
// Poll for unpaired RNodes while BLE is active
|
|
365
|
-
useEffect(() => {
|
|
366
|
-
if (!bleActive) return;
|
|
367
|
-
const id = setInterval(() => {
|
|
368
|
-
try { setUnpairedRNodes(bleUnpairedRNodeCount()); } catch {}
|
|
369
|
-
}, 2000);
|
|
370
|
-
return () => clearInterval(id);
|
|
371
|
-
}, [bleActive, bleUnpairedRNodeCount]);
|
|
372
|
-
|
|
373
|
-
// Live BLE peer count — poll every second
|
|
374
|
-
useEffect(() => {
|
|
375
|
-
if (!bleActive) { setLiveBleCount(0); return; }
|
|
376
|
-
const tick = () => { try { setLiveBleCount(LxmfModule.blePeerCount()); } catch {} };
|
|
377
|
-
tick();
|
|
378
|
-
const id = setInterval(tick, 1000);
|
|
379
|
-
return () => clearInterval(id);
|
|
380
|
-
}, [bleActive]);
|
|
381
|
-
|
|
382
|
-
const onSend = useCallback(async () => {
|
|
383
|
-
const d = dest.trim().toLowerCase();
|
|
384
|
-
if (!/^[0-9a-f]{32}$/.test(d)) { setSendResult('Dest = 32 hex chars.'); return; }
|
|
385
|
-
const r = await send(d, utf8ToBase64(msgText));
|
|
386
|
-
setSendResult(r >= 0 ? `Receipt #${r}` : 'Send failed.');
|
|
387
|
-
}, [dest, msgText, send]);
|
|
388
|
-
|
|
389
|
-
const copyIdentity = useCallback(() => {
|
|
390
|
-
if (status?.identityHex) copyToClipboard(status.identityHex);
|
|
391
|
-
}, [status?.identityHex]);
|
|
392
|
-
|
|
393
|
-
const copyAddress = useCallback(() => {
|
|
394
|
-
if (status?.addressHex) copyToClipboard(status.addressHex);
|
|
395
|
-
}, [status?.addressHex]);
|
|
396
|
-
|
|
397
|
-
// ── Render ────────────────────────────────────────────────────────────────
|
|
398
|
-
|
|
399
|
-
return (
|
|
400
|
-
<ScrollView contentContainerStyle={S.scroll} contentInsetAdjustmentBehavior="automatic">
|
|
401
|
-
|
|
402
|
-
{/* Header */}
|
|
403
|
-
<View style={S.header}>
|
|
404
|
-
<Text style={S.headerTitle}>LXMF Console</Text>
|
|
405
|
-
<View style={S.headerPills}>
|
|
406
|
-
<Pill label="BLE" active={bleActive} />
|
|
407
|
-
<Pill label="TCP" active={tcpActive} />
|
|
408
|
-
<Pill label={isRunning ? 'Running' : 'Stopped'} active={isRunning} />
|
|
409
|
-
</View>
|
|
410
|
-
</View>
|
|
411
|
-
|
|
412
|
-
{/* Error banner */}
|
|
413
|
-
{error ? (
|
|
414
|
-
<View style={S.errorBanner}>
|
|
415
|
-
<Text style={S.errorBannerText}>{error}</Text>
|
|
416
|
-
</View>
|
|
417
|
-
) : null}
|
|
418
|
-
|
|
419
|
-
{/* ── Node Status ─────────────────────────────────────────────────── */}
|
|
420
|
-
<Accordion title="Node Status" defaultOpen>
|
|
421
|
-
<Row label="Native module" value={isNativeAvailable ? 'Loaded ✓' : 'Missing ✗'} />
|
|
422
|
-
<Row label="State" value={isRunning ? 'Running' : 'Stopped'} />
|
|
423
|
-
<Row
|
|
424
|
-
label="Identity"
|
|
425
|
-
value={status?.identityHex ? shortHex(status.identityHex) : '—'}
|
|
426
|
-
onCopy={status?.identityHex ? copyIdentity : undefined}
|
|
427
|
-
/>
|
|
428
|
-
<Row
|
|
429
|
-
label="Address"
|
|
430
|
-
value={status?.addressHex ? shortHex(status.addressHex) : '—'}
|
|
431
|
-
onCopy={status?.addressHex ? copyAddress : undefined}
|
|
432
|
-
/>
|
|
433
|
-
<Row label="Announces" value={String(status?.announcesReceived ?? 0)} />
|
|
434
|
-
<Row label="Messages" value={String(status?.lxmfMessagesReceived ?? 0)} />
|
|
435
|
-
<Row label="Outbound sent" value={String(status?.outboundSent ?? 0)} />
|
|
436
|
-
<Row label="Inbound accepted" value={String(status?.inboundAccepted ?? 0)} />
|
|
437
|
-
<View style={S.btnRow}>
|
|
438
|
-
<Btn label="Refresh" onPress={getStatus} small />
|
|
439
|
-
</View>
|
|
440
|
-
</Accordion>
|
|
441
|
-
|
|
442
|
-
{/* ── TCP / Reticulum ──────────────────────────────────────────────── */}
|
|
443
|
-
<Accordion title="TCP / Reticulum" defaultOpen>
|
|
444
|
-
<Text style={S.hint}>Connect to rnsd daemon. BLE can run simultaneously.</Text>
|
|
445
|
-
<TextInput
|
|
446
|
-
style={S.input}
|
|
447
|
-
placeholder="Host (e.g. 192.168.1.10)"
|
|
448
|
-
placeholderTextColor="#607080"
|
|
449
|
-
value={tcpHost}
|
|
450
|
-
onChangeText={setTcpHost}
|
|
451
|
-
autoCapitalize="none"
|
|
452
|
-
autoCorrect={false}
|
|
453
|
-
/>
|
|
454
|
-
<TextInput
|
|
455
|
-
style={S.input}
|
|
456
|
-
placeholder="Port (default 4242)"
|
|
457
|
-
placeholderTextColor="#607080"
|
|
458
|
-
value={tcpPort}
|
|
459
|
-
onChangeText={setTcpPort}
|
|
460
|
-
keyboardType="number-pad"
|
|
461
|
-
/>
|
|
462
|
-
<TextInput
|
|
463
|
-
style={S.input}
|
|
464
|
-
placeholder="Display name (e.g. lxmf-mobile)"
|
|
465
|
-
placeholderTextColor="#607080"
|
|
466
|
-
value={displayName}
|
|
467
|
-
onChangeText={setDisplayName}
|
|
468
|
-
autoCapitalize="none"
|
|
469
|
-
autoCorrect={false}
|
|
470
|
-
/>
|
|
471
|
-
<View style={S.switchRow}>
|
|
472
|
-
<Text style={S.switchLabel}>Beacon mode</Text>
|
|
473
|
-
<Switch
|
|
474
|
-
value={isBeacon}
|
|
475
|
-
onValueChange={setIsBeacon}
|
|
476
|
-
disabled={isRunning}
|
|
477
|
-
trackColor={{ false: C.border, true: C.accent }}
|
|
478
|
-
thumbColor={isBeacon ? C.accentBright : C.textDim}
|
|
479
|
-
/>
|
|
480
|
-
</View>
|
|
481
|
-
{transportMsg ? <Text style={S.warn}>{transportMsg}</Text> : null}
|
|
482
|
-
<View style={S.btnRow}>
|
|
483
|
-
<Btn label="Start TCP" onPress={onStartTcp} disabled={!isNativeAvailable || isRunning || !identityHydrated} />
|
|
484
|
-
<Btn label="Stop TCP" onPress={onStopTcp} disabled={!isRunning} danger />
|
|
485
|
-
</View>
|
|
486
|
-
</Accordion>
|
|
487
|
-
|
|
488
|
-
{/* ── BLE Mesh ─────────────────────────────────────────────────────── */}
|
|
489
|
-
<Accordion title="BLE Mesh" defaultOpen>
|
|
490
|
-
<Text style={S.hint}>Pair RNodes in iOS Settings > Bluetooth first, then start BLE.</Text>
|
|
491
|
-
<Row label="BLE active" value={bleActive ? 'Yes' : 'No'} />
|
|
492
|
-
<Row label="Connected peers" value={String(liveBleCount)} />
|
|
493
|
-
{unpairedRNodes > 0 && (
|
|
494
|
-
<Text style={S.warn}>
|
|
495
|
-
Found {unpairedRNodes} unpaired RNode{unpairedRNodes > 1 ? 's' : ''}. Open Settings > Bluetooth, pair the device, then restart BLE.
|
|
496
|
-
</Text>
|
|
497
|
-
)}
|
|
498
|
-
<View style={S.btnRow}>
|
|
499
|
-
<Btn label="Start BLE" onPress={onStartBle} disabled={bleActive || !identityHydrated} />
|
|
500
|
-
<Btn label="Stop BLE" onPress={onStopBle} disabled={!bleActive} danger />
|
|
501
|
-
</View>
|
|
502
|
-
</Accordion>
|
|
503
|
-
|
|
504
|
-
{/* ── BLE Peers ────────────────────────────────────────────────────── */}
|
|
505
|
-
<Accordion title="BLE Peers" badge={liveBleCount} defaultOpen>
|
|
506
|
-
<Text style={S.hint}>
|
|
507
|
-
Live BLE connections: {liveBleCount}. LXMF identity hashes appear after peer announces.
|
|
508
|
-
</Text>
|
|
509
|
-
{knownPeerHashes.length === 0 ? (
|
|
510
|
-
<Text style={S.muted}>No peer announces received yet.</Text>
|
|
511
|
-
) : (
|
|
512
|
-
knownPeerHashes.map((p) => (
|
|
513
|
-
<View key={p.hash} style={S.itemCard}>
|
|
514
|
-
{p.name ? <Text style={S.itemTitle}>{p.name}</Text> : null}
|
|
515
|
-
<Text selectable style={S.itemBody}>{p.hash}</Text>
|
|
516
|
-
<Text style={S.itemMeta}>last seen: {p.lastSeen}</Text>
|
|
517
|
-
<View style={S.announceActions}>
|
|
518
|
-
<Pressable style={S.copyBtn} onPress={() => copyToClipboard(p.hash)}>
|
|
519
|
-
<Text style={S.copyBtnText}>⎘</Text>
|
|
520
|
-
</Pressable>
|
|
521
|
-
<Pressable style={S.sendToBtn} onPress={() => { setDest(p.hash); setSendResult(''); }}>
|
|
522
|
-
<Text style={S.sendToBtnText}>→ Send</Text>
|
|
523
|
-
</Pressable>
|
|
524
|
-
</View>
|
|
525
|
-
</View>
|
|
526
|
-
))
|
|
527
|
-
)}
|
|
528
|
-
</Accordion>
|
|
529
|
-
|
|
530
|
-
{/* ── Announces ────────────────────────────────────────────────────── */}
|
|
531
|
-
<Accordion title="Announces" badge={counts.announces} defaultOpen>
|
|
532
|
-
{announceEvts.length === 0 ? (
|
|
533
|
-
<Text style={S.muted}>No announces yet.</Text>
|
|
534
|
-
) : (
|
|
535
|
-
announceEvts.map((e: LxmfEvent, i: number) => {
|
|
536
|
-
const hash = String(e.destHash ?? e.address ?? '');
|
|
537
|
-
const name = e.appData ? String(e.appData) : '';
|
|
538
|
-
return (
|
|
539
|
-
<View key={`${evtKey(e, 'ann-')}-${i}`} style={S.itemCard}>
|
|
540
|
-
<View style={S.announceHeader}>
|
|
541
|
-
<View style={S.announceInfo}>
|
|
542
|
-
{name ? <Text style={S.itemTitle}>{name}</Text> : null}
|
|
543
|
-
<Text selectable style={S.itemBody}>{shortHex(hash)}</Text>
|
|
544
|
-
<Text style={S.itemMeta}>{fmtTime(e)}{e.hops !== undefined ? ` · ${e.hops} hop` : ''}</Text>
|
|
545
|
-
</View>
|
|
546
|
-
<View style={S.announceActions}>
|
|
547
|
-
<Pressable
|
|
548
|
-
style={S.copyBtn}
|
|
549
|
-
onPress={() => copyToClipboard(hash)}>
|
|
550
|
-
<Text style={S.copyBtnText}>⎘</Text>
|
|
551
|
-
</Pressable>
|
|
552
|
-
<Pressable
|
|
553
|
-
style={S.sendToBtn}
|
|
554
|
-
onPress={() => { setDest(hash); setSendResult(''); }}>
|
|
555
|
-
<Text style={S.sendToBtnText}>→ Send</Text>
|
|
556
|
-
</Pressable>
|
|
557
|
-
</View>
|
|
558
|
-
</View>
|
|
559
|
-
</View>
|
|
560
|
-
);
|
|
561
|
-
})
|
|
562
|
-
)}
|
|
563
|
-
</Accordion>
|
|
564
|
-
|
|
565
|
-
{/* ── Send Message ─────────────────────────────────────────────────── */}
|
|
566
|
-
<Accordion title="Send Message" defaultOpen>
|
|
567
|
-
{dest ? (
|
|
568
|
-
<Text style={S.destFilled}>→ {shortHex(dest)}</Text>
|
|
569
|
-
) : (
|
|
570
|
-
<Text style={S.hint}>{'Tap "→ Send" on an announce above to fill the destination.'}</Text>
|
|
571
|
-
)}
|
|
572
|
-
<TextInput
|
|
573
|
-
style={S.input}
|
|
574
|
-
placeholder="Destination (32 hex chars)"
|
|
575
|
-
placeholderTextColor="#607080"
|
|
576
|
-
value={dest}
|
|
577
|
-
onChangeText={setDest}
|
|
578
|
-
autoCapitalize="none"
|
|
579
|
-
autoCorrect={false}
|
|
580
|
-
/>
|
|
581
|
-
<TextInput
|
|
582
|
-
style={S.input}
|
|
583
|
-
placeholder="Message text"
|
|
584
|
-
placeholderTextColor="#607080"
|
|
585
|
-
value={msgText}
|
|
586
|
-
onChangeText={setMsgText}
|
|
587
|
-
/>
|
|
588
|
-
<View style={S.btnRow}>
|
|
589
|
-
<Btn label="Send" onPress={onSend} disabled={!isRunning} />
|
|
590
|
-
<Btn label="Broadcast" onPress={onBroadcast} disabled={!isRunning || !knownPeerHashes.length} />
|
|
591
|
-
</View>
|
|
592
|
-
{sendResult ? <Text style={S.feedback}>{sendResult}</Text> : null}
|
|
593
|
-
</Accordion>
|
|
594
|
-
|
|
595
|
-
{/* ── Messages ─────────────────────────────────────────────────────── */}
|
|
596
|
-
<Accordion title="Messages" badge={counts.messages + storedMsgs.length} defaultOpen>
|
|
597
|
-
{/* Persisted (SQLite) */}
|
|
598
|
-
{storedMsgs.length > 0 && (
|
|
599
|
-
<>
|
|
600
|
-
<Text style={S.sectionLabel}>Persisted ({storedMsgs.length})</Text>
|
|
601
|
-
{storedMsgs.map((m: any, i: number) => {
|
|
602
|
-
const bodyText = base64ToUtf8(m.body ?? '');
|
|
603
|
-
const titleText = m.title ? base64ToUtf8(m.title) : '';
|
|
604
|
-
const sender = m.source ?? m.source_hash ?? '';
|
|
605
|
-
const t = m.timestamp ? new Date(m.timestamp > 10_000_000_000 ? m.timestamp : m.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
|
606
|
-
return (
|
|
607
|
-
<View key={`stored-${i}-${sender}`} style={[S.itemCard, S.storedCard]}>
|
|
608
|
-
<View style={S.announceHeader}>
|
|
609
|
-
<View style={S.announceInfo}>
|
|
610
|
-
<Text selectable style={S.itemTitle}>From: {shortHex(sender)}</Text>
|
|
611
|
-
{titleText ? <Text selectable style={S.msgTitle}>{titleText}</Text> : null}
|
|
612
|
-
{bodyText ? <Text selectable style={S.itemBody}>{bodyText}</Text> : null}
|
|
613
|
-
{t ? <Text style={S.itemMeta}>{t}</Text> : null}
|
|
614
|
-
</View>
|
|
615
|
-
{sender ? (
|
|
616
|
-
<View style={S.announceActions}>
|
|
617
|
-
<Pressable style={S.copyBtn} onPress={() => copyToClipboard(sender)}>
|
|
618
|
-
<Text style={S.copyBtnText}>⎘</Text>
|
|
619
|
-
</Pressable>
|
|
620
|
-
<Pressable style={S.sendToBtn} onPress={() => { setDest(sender); setSendResult(''); }}>
|
|
621
|
-
<Text style={S.sendToBtnText}>↩ Reply</Text>
|
|
622
|
-
</Pressable>
|
|
623
|
-
</View>
|
|
624
|
-
) : null}
|
|
625
|
-
</View>
|
|
626
|
-
</View>
|
|
627
|
-
);
|
|
628
|
-
})}
|
|
629
|
-
</>
|
|
630
|
-
)}
|
|
631
|
-
|
|
632
|
-
{/* Live (in-session) */}
|
|
633
|
-
{msgEvts.length > 0 && <Text style={S.sectionLabel}>Live session</Text>}
|
|
634
|
-
{msgEvts.length === 0 && storedMsgs.length === 0 ? (
|
|
635
|
-
<Text style={S.muted}>No messages yet.</Text>
|
|
636
|
-
) : (
|
|
637
|
-
msgEvts.map((e, i) => {
|
|
638
|
-
const msg = e as unknown as LxmfMessageEvent;
|
|
639
|
-
const bodyText = base64ToUtf8(msg.body ?? '');
|
|
640
|
-
const titleText = msg.title ? base64ToUtf8(msg.title) : '';
|
|
641
|
-
const sender = msg.source ?? '';
|
|
642
|
-
return (
|
|
643
|
-
<View key={`${evtKey(e, 'msg-')}-${i}`} style={S.itemCard}>
|
|
644
|
-
<View style={S.announceHeader}>
|
|
645
|
-
<View style={S.announceInfo}>
|
|
646
|
-
<Text selectable style={S.itemTitle}>From: {shortHex(sender)}</Text>
|
|
647
|
-
{titleText ? <Text selectable style={S.msgTitle}>{titleText}</Text> : null}
|
|
648
|
-
{bodyText ? <Text selectable style={S.itemBody}>{bodyText}</Text> : null}
|
|
649
|
-
{msg.image ? (
|
|
650
|
-
<Text style={S.mediaBadge}>[img: {msg.image.mimeType}]</Text>
|
|
651
|
-
) : null}
|
|
652
|
-
{msg.files?.length ? (
|
|
653
|
-
<Text style={S.mediaBadge}>[{msg.files.length} file{msg.files.length > 1 ? 's' : ''}]</Text>
|
|
654
|
-
) : null}
|
|
655
|
-
<Text style={S.itemMeta}>{fmtTime(e)}</Text>
|
|
656
|
-
</View>
|
|
657
|
-
{sender ? (
|
|
658
|
-
<View style={S.announceActions}>
|
|
659
|
-
<Pressable style={S.copyBtn} onPress={() => copyToClipboard(sender)}>
|
|
660
|
-
<Text style={S.copyBtnText}>⎘</Text>
|
|
661
|
-
</Pressable>
|
|
662
|
-
<Pressable style={S.sendToBtn} onPress={() => { setDest(sender); setSendResult(''); }}>
|
|
663
|
-
<Text style={S.sendToBtnText}>↩ Reply</Text>
|
|
664
|
-
</Pressable>
|
|
665
|
-
</View>
|
|
666
|
-
) : null}
|
|
667
|
-
</View>
|
|
668
|
-
</View>
|
|
669
|
-
);
|
|
670
|
-
})
|
|
671
|
-
)}
|
|
672
|
-
</Accordion>
|
|
673
|
-
|
|
674
|
-
{/* ── Event Log ────────────────────────────────────────────────────── */}
|
|
675
|
-
<Accordion title="Event Log" badge={allEvts.length} defaultOpen={false}>
|
|
676
|
-
{allEvts.length === 0 ? (
|
|
677
|
-
<Text style={S.muted}>No events yet.</Text>
|
|
678
|
-
) : (
|
|
679
|
-
allEvts.map((e, i) => (
|
|
680
|
-
<View key={`${evtKey(e, 'el-')}-${i}`} style={S.logRow}>
|
|
681
|
-
<Text style={S.logTag}>{e.type}</Text>
|
|
682
|
-
<Text selectable style={S.logText} numberOfLines={2}>{evtSummary(e)}</Text>
|
|
683
|
-
<Text style={S.logTime}>{fmtTime(e)}</Text>
|
|
684
|
-
</View>
|
|
685
|
-
))
|
|
686
|
-
)}
|
|
687
|
-
</Accordion>
|
|
688
|
-
|
|
689
|
-
{/* ── Debug Logs ───────────────────────────────────────────────────── */}
|
|
690
|
-
<Accordion title="Debug Logs" badge={counts.logs} defaultOpen>
|
|
691
|
-
{logEvts.length === 0 ? (
|
|
692
|
-
<Text style={S.muted}>No logs yet.</Text>
|
|
693
|
-
) : (
|
|
694
|
-
logEvts.map((e, i) => (
|
|
695
|
-
<View key={`${evtKey(e, 'lg-')}-${i}`} style={S.logRow}>
|
|
696
|
-
<Text style={S.logTime}>{fmtTime(e)}</Text>
|
|
697
|
-
<Text selectable style={S.logLine}>{String(e.message ?? e.msg ?? evtSummary(e))}</Text>
|
|
698
|
-
</View>
|
|
699
|
-
))
|
|
700
|
-
)}
|
|
701
|
-
</Accordion>
|
|
702
|
-
|
|
703
|
-
</ScrollView>
|
|
704
|
-
);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// ── Styles ───────────────────────────────────────────────────────────────────
|
|
708
|
-
|
|
709
|
-
const C = {
|
|
710
|
-
bg: '#0c1218',
|
|
711
|
-
surface: '#131d26',
|
|
712
|
-
border: '#1e3040',
|
|
713
|
-
accent: '#1a7fc1',
|
|
714
|
-
accentBright: '#4fb3e8',
|
|
715
|
-
danger: '#c0392b',
|
|
716
|
-
text: '#d8ecf8',
|
|
717
|
-
textDim: '#7a9db5',
|
|
718
|
-
textMono: '#a8c8dc',
|
|
719
|
-
green: '#2ecc71',
|
|
720
|
-
warn: '#f0a500',
|
|
721
|
-
};
|
|
722
|
-
|
|
723
|
-
const S = StyleSheet.create({
|
|
724
|
-
scroll: {
|
|
725
|
-
paddingHorizontal: 14,
|
|
726
|
-
paddingTop: 14,
|
|
727
|
-
paddingBottom: 60,
|
|
728
|
-
gap: 10,
|
|
729
|
-
backgroundColor: C.bg,
|
|
730
|
-
},
|
|
731
|
-
|
|
732
|
-
// Header
|
|
733
|
-
header: {
|
|
734
|
-
backgroundColor: C.surface,
|
|
735
|
-
borderRadius: 14,
|
|
736
|
-
borderWidth: 1,
|
|
737
|
-
borderColor: C.border,
|
|
738
|
-
padding: 14,
|
|
739
|
-
gap: 10,
|
|
740
|
-
},
|
|
741
|
-
headerTitle: { color: C.text, fontSize: 26, fontWeight: '700' },
|
|
742
|
-
headerPills: { flexDirection: 'row', gap: 8 },
|
|
743
|
-
|
|
744
|
-
pill: {
|
|
745
|
-
borderRadius: 20,
|
|
746
|
-
borderWidth: 1,
|
|
747
|
-
borderColor: C.border,
|
|
748
|
-
paddingHorizontal: 10,
|
|
749
|
-
paddingVertical: 4,
|
|
750
|
-
backgroundColor: '#0e1923',
|
|
751
|
-
},
|
|
752
|
-
pillActive: { borderColor: C.accentBright, backgroundColor: '#0d3550' },
|
|
753
|
-
pillText: { color: C.textDim, fontSize: 12, fontWeight: '600' },
|
|
754
|
-
pillTextActive: { color: C.accentBright },
|
|
755
|
-
|
|
756
|
-
errorBanner: {
|
|
757
|
-
backgroundColor: '#3a1515',
|
|
758
|
-
borderRadius: 10,
|
|
759
|
-
borderWidth: 1,
|
|
760
|
-
borderColor: '#7a2020',
|
|
761
|
-
padding: 10,
|
|
762
|
-
},
|
|
763
|
-
errorBannerText: { color: '#ff9a9a', fontSize: 13 },
|
|
764
|
-
|
|
765
|
-
// Accordion
|
|
766
|
-
accordion: {
|
|
767
|
-
backgroundColor: C.surface,
|
|
768
|
-
borderRadius: 14,
|
|
769
|
-
borderWidth: 1,
|
|
770
|
-
borderColor: C.border,
|
|
771
|
-
overflow: 'hidden',
|
|
772
|
-
},
|
|
773
|
-
accordionHeader: {
|
|
774
|
-
flexDirection: 'row',
|
|
775
|
-
alignItems: 'center',
|
|
776
|
-
paddingHorizontal: 14,
|
|
777
|
-
paddingVertical: 13,
|
|
778
|
-
gap: 8,
|
|
779
|
-
},
|
|
780
|
-
accordionHeaderPressed: { backgroundColor: '#17232e' },
|
|
781
|
-
accordionChevron: { color: C.textDim, fontSize: 14, width: 14 },
|
|
782
|
-
accordionTitle: { color: C.text, fontSize: 16, fontWeight: '600', flex: 1 },
|
|
783
|
-
accordionBadge: {
|
|
784
|
-
backgroundColor: '#0d3550',
|
|
785
|
-
borderRadius: 10,
|
|
786
|
-
paddingHorizontal: 7,
|
|
787
|
-
paddingVertical: 2,
|
|
788
|
-
minWidth: 24,
|
|
789
|
-
alignItems: 'center',
|
|
790
|
-
},
|
|
791
|
-
accordionBadgeText: { color: C.accentBright, fontSize: 11, fontWeight: '700' },
|
|
792
|
-
accordionBody: {
|
|
793
|
-
paddingHorizontal: 14,
|
|
794
|
-
paddingBottom: 14,
|
|
795
|
-
gap: 8,
|
|
796
|
-
borderTopWidth: 1,
|
|
797
|
-
borderTopColor: C.border,
|
|
798
|
-
},
|
|
799
|
-
|
|
800
|
-
// Stat rows
|
|
801
|
-
statRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
|
802
|
-
statLabel: { color: C.textDim, fontSize: 13 },
|
|
803
|
-
statValueRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
804
|
-
statValue: { color: C.text, fontSize: 13, fontFamily: 'monospace' },
|
|
805
|
-
copyBtn: {
|
|
806
|
-
paddingHorizontal: 6,
|
|
807
|
-
paddingVertical: 2,
|
|
808
|
-
borderRadius: 6,
|
|
809
|
-
backgroundColor: '#0d3550',
|
|
810
|
-
borderWidth: 1,
|
|
811
|
-
borderColor: C.border,
|
|
812
|
-
},
|
|
813
|
-
copyBtnText: { color: C.accentBright, fontSize: 13 },
|
|
814
|
-
|
|
815
|
-
hint: { color: C.textDim, fontSize: 12, marginBottom: 2 },
|
|
816
|
-
|
|
817
|
-
// Input
|
|
818
|
-
input: {
|
|
819
|
-
borderWidth: 1,
|
|
820
|
-
borderColor: '#2a4050',
|
|
821
|
-
backgroundColor: '#0b1820',
|
|
822
|
-
color: C.text,
|
|
823
|
-
borderRadius: 10,
|
|
824
|
-
paddingHorizontal: 10,
|
|
825
|
-
paddingVertical: 10,
|
|
826
|
-
fontFamily: 'monospace',
|
|
827
|
-
fontSize: 13,
|
|
828
|
-
},
|
|
829
|
-
|
|
830
|
-
warn: { color: C.warn, fontSize: 12, fontFamily: 'monospace' },
|
|
831
|
-
feedback: { color: C.green, fontSize: 13, fontFamily: 'monospace' },
|
|
832
|
-
muted: { color: C.textDim, fontSize: 13 },
|
|
833
|
-
|
|
834
|
-
// Buttons
|
|
835
|
-
btnRow: { flexDirection: 'row', gap: 8, marginTop: 2 },
|
|
836
|
-
btn: {
|
|
837
|
-
flex: 1,
|
|
838
|
-
borderRadius: 10,
|
|
839
|
-
paddingVertical: 10,
|
|
840
|
-
alignItems: 'center',
|
|
841
|
-
backgroundColor: C.accent,
|
|
842
|
-
},
|
|
843
|
-
btnSmall: { paddingVertical: 7, flex: 0, paddingHorizontal: 16 },
|
|
844
|
-
btnDanger: { backgroundColor: C.danger },
|
|
845
|
-
btnDisabled: { opacity: 0.4 },
|
|
846
|
-
btnPressed: { opacity: 0.78 },
|
|
847
|
-
btnText: { color: '#e8f6ff', fontSize: 14, fontWeight: '600' },
|
|
848
|
-
btnTextSmall: { fontSize: 12 },
|
|
849
|
-
|
|
850
|
-
// Item cards (announces, messages, beacons)
|
|
851
|
-
itemCard: {
|
|
852
|
-
borderWidth: 1,
|
|
853
|
-
borderColor: '#1f3348',
|
|
854
|
-
borderRadius: 10,
|
|
855
|
-
padding: 10,
|
|
856
|
-
backgroundColor: '#0e1e2b',
|
|
857
|
-
gap: 3,
|
|
858
|
-
},
|
|
859
|
-
itemTitle: { color: C.text, fontSize: 13, fontWeight: '600' },
|
|
860
|
-
itemBody: { color: C.textMono, fontSize: 13, fontFamily: 'monospace' },
|
|
861
|
-
itemMeta: { color: C.textDim, fontSize: 11, fontFamily: 'monospace' },
|
|
862
|
-
|
|
863
|
-
// Log rows
|
|
864
|
-
logRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 },
|
|
865
|
-
logTag: { color: C.accentBright, fontFamily: 'monospace', fontSize: 10, width: 100 },
|
|
866
|
-
logText: { color: C.textMono, flex: 1, fontSize: 11, fontFamily: 'monospace' },
|
|
867
|
-
logTime: { color: C.textDim, fontFamily: 'monospace', fontSize: 10 },
|
|
868
|
-
logLine: { color: C.textMono, fontSize: 11, fontFamily: 'monospace' },
|
|
869
|
-
|
|
870
|
-
// Announce card layout
|
|
871
|
-
announceHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
872
|
-
announceInfo: { flex: 1, gap: 2 },
|
|
873
|
-
announceActions: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
|
874
|
-
|
|
875
|
-
// Send-to button on announce cards
|
|
876
|
-
sendToBtn: {
|
|
877
|
-
paddingHorizontal: 8,
|
|
878
|
-
paddingVertical: 4,
|
|
879
|
-
borderRadius: 6,
|
|
880
|
-
backgroundColor: '#0d3550',
|
|
881
|
-
borderWidth: 1,
|
|
882
|
-
borderColor: C.accentBright,
|
|
883
|
-
},
|
|
884
|
-
sendToBtnText: { color: C.accentBright, fontSize: 12, fontWeight: '600' },
|
|
885
|
-
|
|
886
|
-
// Destination pre-filled indicator
|
|
887
|
-
destFilled: { color: C.accentBright, fontSize: 12, fontFamily: 'monospace' },
|
|
888
|
-
|
|
889
|
-
// Beacon mode toggle row
|
|
890
|
-
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 4 },
|
|
891
|
-
switchLabel: { color: C.textDim, fontSize: 13 },
|
|
892
|
-
|
|
893
|
-
// Message card extras
|
|
894
|
-
msgTitle: { color: C.text, fontSize: 13, fontWeight: '600', fontStyle: 'italic' },
|
|
895
|
-
mediaBadge: { color: C.accentBright, fontSize: 11, fontFamily: 'monospace', marginTop: 2 },
|
|
896
|
-
sectionLabel: { color: C.textDim, fontSize: 11, fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 4 },
|
|
897
|
-
storedCard: { borderColor: '#253d50', backgroundColor: '#0b1a25' },
|
|
898
|
-
});
|