@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.
- 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
|
@@ -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
|
+
});
|