@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.
@@ -1,898 +1,5 @@
1
- import { useCallback, useEffect, useMemo, useState } from 'react';
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
- // Persisted identity blob schema (versioned). Stored in expo-secure-store under
18
- // IDENTITY_KEY — encrypted at rest on iOS (Keychain) and Android (Keystore-backed).
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 &gt; 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 &gt; 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
- });