@magicred-1/react-native-lxmf 0.2.34 → 0.2.35

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.
@@ -0,0 +1,329 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ FlatList,
4
+ KeyboardAvoidingView,
5
+ Platform,
6
+ Pressable,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput,
10
+ View,
11
+ } from 'react-native';
12
+ import { useLocalSearchParams, useRouter } from 'expo-router';
13
+ import { useLxmfContext, type StoredMessage } from '@/context/LxmfContext';
14
+
15
+ // ── Helpers ───────────────────────────────────────────────────────────────────
16
+
17
+ function b64decode(b64: string): string {
18
+ if (!b64) return '';
19
+ try {
20
+ const bin = globalThis.atob(b64);
21
+ const bytes = Uint8Array.from(bin, c => c.codePointAt(0) ?? 0);
22
+ return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
23
+ } catch {
24
+ return '';
25
+ }
26
+ }
27
+
28
+ function b64encode(s: string): string {
29
+ if (typeof globalThis.btoa === 'function') {
30
+ try {
31
+ return globalThis.btoa(
32
+ Array.from(new TextEncoder().encode(s), b => String.fromCodePoint(b)).join(''),
33
+ );
34
+ } catch {}
35
+ }
36
+ return s;
37
+ }
38
+
39
+ function fmtTime(unix: number): string {
40
+ const d = new Date(unix > 10_000_000_000 ? unix : unix * 1000);
41
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
42
+ }
43
+
44
+ function shortHex(v: string): string {
45
+ if (!v || v.length <= 12) return v || '—';
46
+ return `${v.slice(0, 6)}…${v.slice(-6)}`;
47
+ }
48
+
49
+ // ── Types ─────────────────────────────────────────────────────────────────────
50
+
51
+ type BubbleMsg = {
52
+ key: string;
53
+ outbound: boolean;
54
+ title: string;
55
+ body: string;
56
+ timestamp: number;
57
+ acked: boolean;
58
+ image?: { mimeType: string; data: string };
59
+ files?: { name: string; data: string }[];
60
+ };
61
+
62
+ // ── Bubble ────────────────────────────────────────────────────────────────────
63
+
64
+ function Bubble({ msg }: Readonly<{ msg: BubbleMsg }>) {
65
+ const body = b64decode(msg.body);
66
+ const title = b64decode(msg.title);
67
+ return (
68
+ <View style={[S.bubbleWrap, msg.outbound ? S.bubbleRight : S.bubbleLeft]}>
69
+ <View style={[S.bubble, msg.outbound ? S.bubbleOut : S.bubbleIn]}>
70
+ {title ? <Text style={S.bubbleTitle}>{title}</Text> : null}
71
+ {body ? <Text selectable style={S.bubbleBody}>{body}</Text> : null}
72
+ {msg.image ? <Text style={S.mediaBadge}>[Image: {msg.image.mimeType}]</Text> : null}
73
+ {msg.files?.length ? <Text style={S.mediaBadge}>[{msg.files.length} file{msg.files.length > 1 ? 's' : ''}]</Text> : null}
74
+ <View style={S.bubbleMeta}>
75
+ <Text style={S.bubbleTime}>{fmtTime(msg.timestamp)}</Text>
76
+ {msg.outbound && msg.acked && <Text style={S.ackMark}> ✓</Text>}
77
+ </View>
78
+ </View>
79
+ </View>
80
+ );
81
+ }
82
+
83
+ // ── Main screen ───────────────────────────────────────────────────────────────
84
+
85
+ export default function ConversationScreen() {
86
+ const { address } = useLocalSearchParams<{ address: string }>();
87
+ const router = useRouter();
88
+ const { events, send, fetchMessages, markRead, contacts, upsertContact } = useLxmfContext();
89
+
90
+ const [text, setText] = useState('');
91
+ const [sending, setSending] = useState(false);
92
+ const [sendErr, setSendErr] = useState('');
93
+ const listRef = useRef<FlatList>(null);
94
+
95
+ const contact = contacts.find(c => c.address === address);
96
+ const peerName = contact?.name || shortHex(address ?? '');
97
+
98
+ // Mark thread as read on open
99
+ useEffect(() => {
100
+ if (address) markRead(address);
101
+ }, [address, markRead]);
102
+
103
+ // Load SQLite history + merge with live events
104
+ const [sqlMsgs, setSqlMsgs] = useState<StoredMessage[]>([]);
105
+
106
+ const loadHistory = useCallback(() => {
107
+ if (!address) return;
108
+ const all = fetchMessages(100);
109
+ const forThread = all.filter(m =>
110
+ m.source === address || m.dest === address
111
+ );
112
+ setSqlMsgs(forThread);
113
+ }, [address, fetchMessages]);
114
+
115
+ useEffect(() => { loadHistory(); }, [loadHistory]);
116
+
117
+ // Refresh when new message arrives for this thread
118
+ useEffect(() => {
119
+ const latest = events[0];
120
+ if (!latest) return;
121
+ if (latest.type === 'messageReceived' && latest.source === address) {
122
+ loadHistory();
123
+ markRead(address ?? '');
124
+ }
125
+ }, [events, address, loadHistory, markRead]);
126
+
127
+ // Build merged, sorted, deduped bubble list
128
+ const bubbles = useMemo((): BubbleMsg[] => {
129
+ const sqlKeys = new Set(sqlMsgs.map(m => String(m.id)));
130
+ const liveExtra: BubbleMsg[] = events
131
+ .filter(e => e.type === 'messageReceived' && e.source === address)
132
+ .filter(e => !sqlKeys.has(String(e.id)))
133
+ .map(e => ({
134
+ key: `live-${e.source}-${e.timestamp ?? Date.now()}`,
135
+ outbound: false,
136
+ title: String(e.title ?? ''),
137
+ body: String(e.body ?? ''),
138
+ timestamp: typeof e.timestamp === 'number' ? e.timestamp : Math.floor(Date.now() / 1000),
139
+ acked: false,
140
+ image: e.image,
141
+ files: e.files,
142
+ }));
143
+
144
+ const fromSql: BubbleMsg[] = sqlMsgs.map(m => ({
145
+ key: `sql-${m.id}`,
146
+ outbound: m.outbound,
147
+ title: m.title ?? '',
148
+ body: m.body ?? '',
149
+ timestamp: m.timestamp,
150
+ acked: m.acked,
151
+ image: m.image,
152
+ files: m.files,
153
+ }));
154
+
155
+ return [...fromSql, ...liveExtra].sort((a, b) => a.timestamp - b.timestamp);
156
+ }, [sqlMsgs, events, address]);
157
+
158
+ // Scroll to bottom when new bubble arrives
159
+ useEffect(() => {
160
+ if (bubbles.length > 0) {
161
+ setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
162
+ }
163
+ }, [bubbles.length]);
164
+
165
+ const onSend = useCallback(async () => {
166
+ const trimmed = text.trim();
167
+ if (!trimmed || !address) return;
168
+ setSending(true);
169
+ setSendErr('');
170
+ const r = await send(address, b64encode(trimmed));
171
+ setSending(false);
172
+ if (r >= 0) {
173
+ setText('');
174
+ upsertContact(address, { lastMessage: trimmed });
175
+ loadHistory();
176
+ } else {
177
+ setSendErr('Send failed — message queued for retry.');
178
+ }
179
+ }, [text, address, send, upsertContact, loadHistory]);
180
+
181
+ const renderBubble = useCallback(({ item }: { item: BubbleMsg }) => (
182
+ <Bubble msg={item} />
183
+ ), []);
184
+
185
+ const keyExtractor = useCallback((item: BubbleMsg) => item.key, []);
186
+
187
+ return (
188
+ <KeyboardAvoidingView
189
+ style={S.root}
190
+ behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
191
+ keyboardVerticalOffset={0}>
192
+
193
+ {/* Header */}
194
+ <View style={S.header}>
195
+ <Pressable style={({ pressed }) => [S.backBtn, pressed && { opacity: 0.7 }]} onPress={() => router.back()}>
196
+ <Text style={S.backBtnText}>‹</Text>
197
+ </Pressable>
198
+ <View style={S.headerCenter}>
199
+ <Text style={S.headerName} numberOfLines={1}>{peerName}</Text>
200
+ <Text selectable style={S.headerAddr}>{shortHex(address ?? '')}</Text>
201
+ </View>
202
+ </View>
203
+
204
+ {/* Message list */}
205
+ <FlatList
206
+ ref={listRef}
207
+ data={bubbles}
208
+ keyExtractor={keyExtractor}
209
+ renderItem={renderBubble}
210
+ contentContainerStyle={S.list}
211
+ ListEmptyComponent={<Text style={S.empty}>No messages yet. Send the first one.</Text>}
212
+ onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
213
+ />
214
+
215
+ {/* Compose bar */}
216
+ <View style={S.composeWrap}>
217
+ {sendErr ? <Text style={S.sendErr}>{sendErr}</Text> : null}
218
+ <View style={S.compose}>
219
+ <TextInput
220
+ style={S.composeInput}
221
+ placeholder="Message…"
222
+ placeholderTextColor="#4a6070"
223
+ value={text}
224
+ onChangeText={setText}
225
+ multiline
226
+ maxLength={2000}
227
+ />
228
+ <Pressable
229
+ style={({ pressed }) => [S.sendBtn, (!text.trim() || sending) && S.sendBtnDisabled, pressed && { opacity: 0.75 }]}
230
+ onPress={onSend}
231
+ disabled={!text.trim() || sending}>
232
+ <Text style={S.sendBtnText}>{sending ? '…' : '↑'}</Text>
233
+ </Pressable>
234
+ </View>
235
+ </View>
236
+ </KeyboardAvoidingView>
237
+ );
238
+ }
239
+
240
+ // ── Styles ────────────────────────────────────────────────────────────────────
241
+
242
+ const S = StyleSheet.create({
243
+ root: { flex: 1, backgroundColor: '#0c1218' },
244
+
245
+ header: {
246
+ flexDirection: 'row',
247
+ alignItems: 'center',
248
+ paddingTop: 50,
249
+ paddingBottom: 12,
250
+ paddingHorizontal: 12,
251
+ backgroundColor: '#131d26',
252
+ borderBottomWidth: 1,
253
+ borderBottomColor: '#1e3040',
254
+ gap: 10,
255
+ },
256
+ backBtn: { paddingHorizontal: 8, paddingVertical: 4 },
257
+ backBtnText: { color: '#1a7fc1', fontSize: 30, lineHeight: 34 },
258
+ headerCenter: { flex: 1 },
259
+ headerName: { color: '#d8ecf8', fontSize: 16, fontWeight: '700' },
260
+ headerAddr: { color: '#4a6070', fontSize: 11, fontFamily: 'monospace' },
261
+
262
+ list: { paddingHorizontal: 12, paddingVertical: 12, gap: 6, flexGrow: 1 },
263
+
264
+ empty: { color: '#4a6070', fontSize: 14, textAlign: 'center', marginTop: 60 },
265
+
266
+ bubbleWrap: { flexDirection: 'row', marginVertical: 2 },
267
+ bubbleLeft: { justifyContent: 'flex-start' },
268
+ bubbleRight: { justifyContent: 'flex-end' },
269
+
270
+ bubble: {
271
+ maxWidth: '78%',
272
+ borderRadius: 16,
273
+ paddingHorizontal: 12,
274
+ paddingVertical: 8,
275
+ gap: 3,
276
+ },
277
+ bubbleIn: {
278
+ backgroundColor: '#1a2a38',
279
+ borderWidth: 1,
280
+ borderColor: '#1e3040',
281
+ borderBottomLeftRadius: 4,
282
+ },
283
+ bubbleOut: {
284
+ backgroundColor: '#1a7fc1',
285
+ borderBottomRightRadius: 4,
286
+ },
287
+
288
+ bubbleTitle: { color: '#d8ecf8', fontSize: 13, fontWeight: '600', fontStyle: 'italic' },
289
+ bubbleBody: { color: '#d8ecf8', fontSize: 14, lineHeight: 20 },
290
+ mediaBadge: { color: '#4fb3e8', fontSize: 11, fontFamily: 'monospace' },
291
+
292
+ bubbleMeta: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', marginTop: 2 },
293
+ bubbleTime: { color: 'rgba(216,236,248,0.55)', fontSize: 10 },
294
+ ackMark: { color: 'rgba(216,236,248,0.75)', fontSize: 10 },
295
+
296
+ composeWrap: {
297
+ borderTopWidth: 1,
298
+ borderTopColor: '#1e3040',
299
+ backgroundColor: '#0e1923',
300
+ paddingHorizontal: 12,
301
+ paddingVertical: 10,
302
+ paddingBottom: Platform.OS === 'ios' ? 28 : 10,
303
+ gap: 6,
304
+ },
305
+ sendErr: { color: '#f0a500', fontSize: 12, fontFamily: 'monospace' },
306
+ compose: { flexDirection: 'row', alignItems: 'flex-end', gap: 10 },
307
+ composeInput: {
308
+ flex: 1,
309
+ borderWidth: 1,
310
+ borderColor: '#2a4050',
311
+ backgroundColor: '#0b1820',
312
+ color: '#d8ecf8',
313
+ borderRadius: 20,
314
+ paddingHorizontal: 14,
315
+ paddingVertical: 10,
316
+ fontSize: 15,
317
+ maxHeight: 120,
318
+ },
319
+ sendBtn: {
320
+ width: 42,
321
+ height: 42,
322
+ borderRadius: 21,
323
+ backgroundColor: '#1a7fc1',
324
+ alignItems: 'center',
325
+ justifyContent: 'center',
326
+ },
327
+ sendBtnDisabled: { opacity: 0.35 },
328
+ sendBtnText: { color: '#fff', fontSize: 20, fontWeight: '700' },
329
+ });
@@ -18,6 +18,9 @@ const MAPPING = {
18
18
  'paperplane.fill': 'send',
19
19
  'chevron.left.forwardslash.chevron.right': 'code',
20
20
  'chevron.right': 'chevron-right',
21
+ 'message.fill': 'chat',
22
+ 'wifi': 'wifi',
23
+ 'gearshape.fill': 'settings',
21
24
  } as IconMapping;
22
25
 
23
26
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicred-1/react-native-lxmf",
3
- "version": "0.2.34",
3
+ "version": "0.2.35",
4
4
  "description": "LXMF Reticulum mesh networking for React Native + Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",