@magicred-1/react-native-lxmf 0.2.44 → 0.2.47

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.
@@ -175,6 +175,26 @@ class LxmfModule : Module() {
175
175
  Function("pairNusRNode") { mac: String ->
176
176
  nusManager?.pairRNode(mac) ?: false
177
177
  }
178
+
179
+ // --- Group Chat ---
180
+
181
+ Function("createGroup") { name: String, keyHex: String ->
182
+ nativeCreateGroup(name, keyHex) ?: throw RuntimeException("createGroup failed")
183
+ }
184
+
185
+ Function("joinGroup") { addrHex: String, keyHex: String ->
186
+ nativeJoinGroup(addrHex, keyHex) == 0
187
+ }
188
+
189
+ Function("leaveGroup") { addrHex: String ->
190
+ nativeLeaveGroup(addrHex) == 0
191
+ }
192
+
193
+ AsyncFunction("sendGroup") { addrHex: String, bodyBase64: String, fieldsJson: String? ->
194
+ val seq = nativeSendGroup(addrHex, bodyBase64, fieldsJson)
195
+ if (seq < 0) throw RuntimeException("sendGroup failed")
196
+ seq.toDouble()
197
+ }
178
198
  }
179
199
 
180
200
  private fun drainAndEmitEvents() {
@@ -246,6 +266,10 @@ class LxmfModule : Module() {
246
266
  private external fun nativeBeaconRpc(destHashHex: String, method: String, paramsJson: String?): Long
247
267
  private external fun nativeSetLogLevel(level: Int): Int
248
268
  private external fun nativeAbiVersion(): Int
269
+ private external fun nativeCreateGroup(name: String, keyHex: String): String?
270
+ private external fun nativeJoinGroup(addrHex: String, keyHex: String): Int
271
+ private external fun nativeLeaveGroup(addrHex: String): Int
272
+ private external fun nativeSendGroup(addrHex: String, bodyBase64: String, fieldsJson: String?): Long
249
273
 
250
274
  // NUS JNI — called by NusManager (same package)
251
275
  external fun nativeNusReceive(data: ByteArray)
@@ -21,6 +21,10 @@ export type NativeModuleType = {
21
21
  bleUnpairedRNodeCount(): number;
22
22
  getNusUnpairedRNodes(): string;
23
23
  pairNusRNode(mac: string): boolean;
24
+ createGroup(name: string, keyHex: string): string;
25
+ joinGroup(addrHex: string, keyHex: string): boolean;
26
+ leaveGroup(addrHex: string): boolean;
27
+ sendGroup(addrHex: string, bodyBase64: string, fieldsJson?: string): Promise<number>;
24
28
  };
25
29
  declare const LxmfModuleNative: NativeModuleType | null;
26
30
  export declare const isLxmfNativeAvailable: boolean;
@@ -37,5 +37,9 @@ const missingNativeShim = {
37
37
  beaconRpc: async () => throwMissingNative(),
38
38
  getNusUnpairedRNodes: () => throwMissingNative(),
39
39
  pairNusRNode: () => throwMissingNative(),
40
+ createGroup: () => throwMissingNative(),
41
+ joinGroup: () => throwMissingNative(),
42
+ leaveGroup: () => throwMissingNative(),
43
+ sendGroup: async () => throwMissingNative(),
40
44
  };
41
45
  exports.LxmfModule = LxmfModuleNative ?? missingNativeShim;
@@ -127,4 +127,8 @@ export declare function useLxmf(options?: UseLxmfOptions): {
127
127
  }[];
128
128
  pairNusRNode: (mac: string) => boolean;
129
129
  beaconRpc: (destHashHex: string, method: string, params?: unknown) => Promise<number>;
130
+ createGroup: (name: string, keyHex: string) => string;
131
+ joinGroup: (addrHex: string, keyHex: string) => boolean;
132
+ leaveGroup: (addrHex: string) => boolean;
133
+ sendGroup: (addrHex: string, bodyBase64: string, media?: LxmfMedia) => Promise<number>;
130
134
  };
package/build/useLxmf.js CHANGED
@@ -297,6 +297,47 @@ function useLxmf(options = {}) {
297
297
  return -1;
298
298
  }
299
299
  }, []);
300
+ /** Create a group channel with a shared AES key. Returns the group address hex. */
301
+ const createGroup = (0, react_1.useCallback)((name, keyHex) => {
302
+ try {
303
+ return LxmfModule_1.LxmfModule.createGroup(name, keyHex);
304
+ }
305
+ catch (e) {
306
+ setError(e?.message ?? 'createGroup failed');
307
+ return '';
308
+ }
309
+ }, []);
310
+ /** Join an existing group channel by address and shared AES key. */
311
+ const joinGroup = (0, react_1.useCallback)((addrHex, keyHex) => {
312
+ try {
313
+ return LxmfModule_1.LxmfModule.joinGroup(addrHex, keyHex);
314
+ }
315
+ catch (e) {
316
+ setError(e?.message ?? 'joinGroup failed');
317
+ return false;
318
+ }
319
+ }, []);
320
+ /** Leave a group channel and forget its key. */
321
+ const leaveGroup = (0, react_1.useCallback)((addrHex) => {
322
+ try {
323
+ return LxmfModule_1.LxmfModule.leaveGroup(addrHex);
324
+ }
325
+ catch (e) {
326
+ setError(e?.message ?? 'leaveGroup failed');
327
+ return false;
328
+ }
329
+ }, []);
330
+ /** Send a message to a group channel. Returns sequence number or -1 on error. */
331
+ const sendGroup = (0, react_1.useCallback)(async (addrHex, bodyBase64, media) => {
332
+ try {
333
+ const fieldsJson = media ? JSON.stringify(media) : undefined;
334
+ return await LxmfModule_1.LxmfModule.sendGroup(addrHex, bodyBase64, fieldsJson);
335
+ }
336
+ catch (e) {
337
+ setError(e?.message ?? 'sendGroup failed');
338
+ return -1;
339
+ }
340
+ }, []);
300
341
  return {
301
342
  // State
302
343
  status,
@@ -321,5 +362,9 @@ function useLxmf(options = {}) {
321
362
  getNusUnpairedRNodes,
322
363
  pairNusRNode,
323
364
  beaconRpc,
365
+ createGroup,
366
+ joinGroup,
367
+ leaveGroup,
368
+ sendGroup,
324
369
  };
325
370
  }
@@ -9,7 +9,7 @@ import {
9
9
  View,
10
10
  } from 'react-native';
11
11
  import { useRouter } from 'expo-router';
12
- import { useLxmfContext, type Contact } from '@/context/LxmfContext';
12
+ import { useLxmfContext, type Contact, type Group } from '@/context/LxmfContext';
13
13
 
14
14
  // ── Helpers ───────────────────────────────────────────────────────────────────
15
15
 
@@ -26,6 +26,33 @@ function relTime(unix: number): string {
26
26
  return `${Math.floor(diff / 86400)}d ago`;
27
27
  }
28
28
 
29
+ // ── List item types ───────────────────────────────────────────────────────────
30
+
31
+ type ListItem =
32
+ | { kind: 'group'; data: Group }
33
+ | { kind: 'contact'; data: Contact };
34
+
35
+ // ── Group row ─────────────────────────────────────────────────────────────────
36
+
37
+ function GroupRow({ group, onPress }: Readonly<{ group: Group; onPress: () => void }>) {
38
+ return (
39
+ <Pressable
40
+ style={({ pressed }) => [S.row, pressed && S.rowPressed]}
41
+ onPress={onPress}>
42
+ <View style={[S.avatar, S.avatarGroup]}>
43
+ <Text style={[S.avatarText, S.avatarTextGroup]}>#</Text>
44
+ </View>
45
+ <View style={S.rowBody}>
46
+ <View style={S.rowTop}>
47
+ <Text style={S.rowName} numberOfLines={1}>{group.name}</Text>
48
+ <Text style={S.rowGroupBadge}>GROUP</Text>
49
+ </View>
50
+ <Text style={S.rowPreview} numberOfLines={1}>{shortHex(group.addrHex)}</Text>
51
+ </View>
52
+ </Pressable>
53
+ );
54
+ }
55
+
29
56
  // ── Contact row ───────────────────────────────────────────────────────────────
30
57
 
31
58
  function ContactRow({ contact, onPress }: Readonly<{ contact: Contact; onPress: () => void }>) {
@@ -57,12 +84,168 @@ function ContactRow({ contact, onPress }: Readonly<{ contact: Contact; onPress:
57
84
  );
58
85
  }
59
86
 
87
+ // ── Group create/join modal ───────────────────────────────────────────────────
88
+
89
+ type GroupModalProps = {
90
+ visible: boolean;
91
+ onClose: () => void;
92
+ onCreated: (addrHex: string, name: string) => void;
93
+ onJoined: (addrHex: string) => void;
94
+ createGroup: (name: string) => string;
95
+ joinGroup: (addrHex: string, keyHex: string) => boolean;
96
+ };
97
+
98
+ function GroupModal({ visible, onClose, onCreated, onJoined, createGroup, joinGroup }: Readonly<GroupModalProps>) {
99
+ const [tab, setTab] = useState<'create' | 'join'>('create');
100
+ const [createName, setCreateName] = useState('');
101
+ const [joinAddr, setJoinAddr] = useState('');
102
+ const [joinKey, setJoinKey] = useState('');
103
+ const [err, setErr] = useState('');
104
+ const [created, setCreated] = useState<{ addrHex: string; name: string } | null>(null);
105
+
106
+ const reset = () => {
107
+ setCreateName('');
108
+ setJoinAddr('');
109
+ setJoinKey('');
110
+ setErr('');
111
+ setCreated(null);
112
+ setTab('create');
113
+ };
114
+
115
+ const handleClose = () => { reset(); onClose(); };
116
+
117
+ const handleCreate = () => {
118
+ const name = createName.trim();
119
+ if (!name) { setErr('Enter a group name.'); return; }
120
+ try {
121
+ const addrHex = createGroup(name);
122
+ setCreated({ addrHex, name });
123
+ setErr('');
124
+ } catch (e: any) {
125
+ setErr(e?.message ?? 'Failed to create group.');
126
+ }
127
+ };
128
+
129
+ const handleJoin = () => {
130
+ const addr = joinAddr.trim().toLowerCase();
131
+ const key = joinKey.trim().toLowerCase();
132
+ if (!/^[0-9a-f]{32}$/.test(addr)) { setErr('Address must be 32 hex chars.'); return; }
133
+ if (!/^[0-9a-f]{64}$/.test(key)) { setErr('Key must be 64 hex chars (32 bytes).'); return; }
134
+ const ok = joinGroup(addr, key);
135
+ if (ok) { onJoined(addr); handleClose(); }
136
+ else { setErr('Failed to join group.'); }
137
+ };
138
+
139
+ const handleDone = () => {
140
+ if (created) onCreated(created.addrHex, created.name);
141
+ handleClose();
142
+ };
143
+
144
+ return (
145
+ <Modal visible={visible} transparent animationType="fade" onRequestClose={handleClose}>
146
+ <Pressable style={S.overlay} onPress={handleClose}>
147
+ <Pressable style={S.modal}>
148
+ <Text style={S.modalTitle}>Group Channel</Text>
149
+
150
+ {/* Tabs */}
151
+ <View style={S.tabs}>
152
+ <Pressable style={[S.tab, tab === 'create' && S.tabActive]} onPress={() => { setTab('create'); setErr(''); }}>
153
+ <Text style={[S.tabText, tab === 'create' && S.tabTextActive]}>Create</Text>
154
+ </Pressable>
155
+ <Pressable style={[S.tab, tab === 'join' && S.tabActive]} onPress={() => { setTab('join'); setErr(''); }}>
156
+ <Text style={[S.tabText, tab === 'join' && S.tabTextActive]}>Join</Text>
157
+ </Pressable>
158
+ </View>
159
+
160
+ {tab === 'create' && !created && (
161
+ <>
162
+ <Text style={S.modalHint}>Choose a channel name. Everyone with the same name + key can communicate.</Text>
163
+ <TextInput
164
+ style={S.modalInput}
165
+ placeholder="e.g. team-alpha"
166
+ placeholderTextColor="#4a6070"
167
+ value={createName}
168
+ onChangeText={t => { setCreateName(t); setErr(''); }}
169
+ autoFocus
170
+ autoCapitalize="none"
171
+ autoCorrect={false}
172
+ />
173
+ {err ? <Text style={S.modalError}>{err}</Text> : null}
174
+ <View style={S.modalBtns}>
175
+ <Pressable style={[S.modalBtn, S.modalBtnCancel]} onPress={handleClose}>
176
+ <Text style={S.modalBtnText}>Cancel</Text>
177
+ </Pressable>
178
+ <Pressable style={[S.modalBtn, S.modalBtnOk]} onPress={handleCreate}>
179
+ <Text style={S.modalBtnText}>Create</Text>
180
+ </Pressable>
181
+ </View>
182
+ </>
183
+ )}
184
+
185
+ {tab === 'create' && created && (
186
+ <>
187
+ <Text style={S.modalHint}>Group created. Share these details with members.</Text>
188
+ <View style={S.inviteBox}>
189
+ <Text style={S.inviteLabel}>Name</Text>
190
+ <Text selectable style={S.inviteValue}>{created.name}</Text>
191
+ <Text style={S.inviteLabel}>Address</Text>
192
+ <Text selectable style={S.inviteValue}>{created.addrHex}</Text>
193
+ </View>
194
+ <Text style={S.modalHint}>Members need the address AND key (tap Share to send both).</Text>
195
+ <View style={S.modalBtns}>
196
+ <Pressable style={[S.modalBtn, S.modalBtnCancel]} onPress={handleDone}>
197
+ <Text style={S.modalBtnText}>Open Chat</Text>
198
+ </Pressable>
199
+ </View>
200
+ </>
201
+ )}
202
+
203
+ {tab === 'join' && (
204
+ <>
205
+ <Text style={S.modalHint}>Enter the group address and shared key from an invite.</Text>
206
+ <TextInput
207
+ style={S.modalInput}
208
+ placeholder="Group address (32 hex chars)"
209
+ placeholderTextColor="#4a6070"
210
+ value={joinAddr}
211
+ onChangeText={t => { setJoinAddr(t); setErr(''); }}
212
+ autoFocus
213
+ autoCapitalize="none"
214
+ autoCorrect={false}
215
+ />
216
+ <TextInput
217
+ style={S.modalInput}
218
+ placeholder="Key (64 hex chars)"
219
+ placeholderTextColor="#4a6070"
220
+ value={joinKey}
221
+ onChangeText={t => { setJoinKey(t); setErr(''); }}
222
+ autoCapitalize="none"
223
+ autoCorrect={false}
224
+ />
225
+ {err ? <Text style={S.modalError}>{err}</Text> : null}
226
+ <View style={S.modalBtns}>
227
+ <Pressable style={[S.modalBtn, S.modalBtnCancel]} onPress={handleClose}>
228
+ <Text style={S.modalBtnText}>Cancel</Text>
229
+ </Pressable>
230
+ <Pressable style={[S.modalBtn, S.modalBtnOk]} onPress={handleJoin}>
231
+ <Text style={S.modalBtnText}>Join</Text>
232
+ </Pressable>
233
+ </View>
234
+ </>
235
+ )}
236
+ </Pressable>
237
+ </Pressable>
238
+ </Modal>
239
+ );
240
+ }
241
+
60
242
  // ── Main screen ───────────────────────────────────────────────────────────────
61
243
 
62
244
  export default function ConversationsScreen() {
63
- const { contacts, upsertContact, isRunning } = useLxmfContext();
245
+ const { contacts, groups, upsertContact, createGroup, joinGroup, isRunning } = useLxmfContext();
64
246
  const router = useRouter();
65
247
  const [showNew, setShowNew] = useState(false);
248
+ const [showGroup, setShowGroup] = useState(false);
66
249
  const [newAddr, setNewAddr] = useState('');
67
250
  const [addrError, setAddrError] = useState('');
68
251
 
@@ -83,11 +266,22 @@ export default function ConversationsScreen() {
83
266
  openThread(addr);
84
267
  }, [newAddr, upsertContact, openThread]);
85
268
 
86
- const renderItem = useCallback(({ item }: { item: Contact }) => (
87
- <ContactRow contact={item} onPress={() => openThread(item.address)} />
88
- ), [openThread]);
269
+ // Unified list: groups first, then contacts
270
+ const listData: ListItem[] = [
271
+ ...groups.map(g => ({ kind: 'group' as const, data: g })),
272
+ ...contacts.map(c => ({ kind: 'contact' as const, data: c })),
273
+ ];
89
274
 
90
- const keyExtractor = useCallback((item: Contact) => item.address, []);
275
+ const renderItem = useCallback(({ item }: { item: ListItem }) => {
276
+ if (item.kind === 'group') {
277
+ return <GroupRow group={item.data} onPress={() => openThread(item.data.addrHex)} />;
278
+ }
279
+ return <ContactRow contact={item.data} onPress={() => openThread(item.data.address)} />;
280
+ }, [openThread]);
281
+
282
+ const keyExtractor = useCallback((item: ListItem) => {
283
+ return item.kind === 'group' ? `g:${item.data.addrHex}` : `c:${item.data.address}`;
284
+ }, []);
91
285
 
92
286
  return (
93
287
  <View style={S.root}>
@@ -98,17 +292,17 @@ export default function ConversationsScreen() {
98
292
  )}
99
293
  </View>
100
294
 
101
- {contacts.length === 0 ? (
295
+ {listData.length === 0 ? (
102
296
  <View style={S.empty}>
103
297
  <Text style={S.emptyTitle}>No contacts yet</Text>
104
298
  <Text style={S.emptyBody}>
105
299
  Peer announces appear here automatically.{'\n'}
106
- Tap + to message a known address.
300
+ Tap + to message a known address or create a group.
107
301
  </Text>
108
302
  </View>
109
303
  ) : (
110
304
  <FlatList
111
- data={contacts}
305
+ data={listData}
112
306
  keyExtractor={keyExtractor}
113
307
  renderItem={renderItem}
114
308
  contentContainerStyle={S.list}
@@ -116,12 +310,17 @@ export default function ConversationsScreen() {
116
310
  />
117
311
  )}
118
312
 
119
- {/* FAB */}
120
- <Pressable style={({ pressed }) => [S.fab, pressed && S.fabPressed]} onPress={() => setShowNew(true)}>
121
- <Text style={S.fabText}>+</Text>
122
- </Pressable>
313
+ {/* FAB row */}
314
+ <View style={S.fabRow}>
315
+ <Pressable style={({ pressed }) => [S.fab, S.fabGroup, pressed && S.fabPressed]} onPress={() => setShowGroup(true)}>
316
+ <Text style={S.fabText}>#</Text>
317
+ </Pressable>
318
+ <Pressable style={({ pressed }) => [S.fab, pressed && S.fabPressed]} onPress={() => setShowNew(true)}>
319
+ <Text style={S.fabText}>+</Text>
320
+ </Pressable>
321
+ </View>
123
322
 
124
- {/* New conversation modal */}
323
+ {/* New direct message modal */}
125
324
  <Modal visible={showNew} transparent animationType="fade" onRequestClose={() => setShowNew(false)}>
126
325
  <Pressable style={S.overlay} onPress={() => setShowNew(false)}>
127
326
  <Pressable style={S.modal}>
@@ -149,6 +348,16 @@ export default function ConversationsScreen() {
149
348
  </Pressable>
150
349
  </Pressable>
151
350
  </Modal>
351
+
352
+ {/* Group create/join modal */}
353
+ <GroupModal
354
+ visible={showGroup}
355
+ onClose={() => setShowGroup(false)}
356
+ createGroup={createGroup}
357
+ joinGroup={joinGroup}
358
+ onCreated={(addrHex) => { setShowGroup(false); openThread(addrHex); }}
359
+ onJoined={(addrHex) => { setShowGroup(false); openThread(addrHex); }}
360
+ />
152
361
  </View>
153
362
  );
154
363
  }
@@ -165,6 +374,8 @@ const C = {
165
374
  border: '#1e3040',
166
375
  accent: '#1a7fc1',
167
376
  accentBright: '#4fb3e8',
377
+ group: '#1a8c6a',
378
+ groupBright: '#3edba8',
168
379
  text: '#d8ecf8',
169
380
  textDim: '#7a9db5',
170
381
  warn: '#f0a500',
@@ -184,7 +395,7 @@ const S = StyleSheet.create({
184
395
  headerTitle: { color: C.text, fontSize: 28, fontWeight: '700' },
185
396
  headerHint: { color: C.warn, fontSize: 12, marginTop: 4 },
186
397
 
187
- list: { paddingBottom: 80 },
398
+ list: { paddingBottom: 100 },
188
399
  separator: { height: 1, backgroundColor: C.border, marginLeft: 72 },
189
400
 
190
401
  row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, backgroundColor: C.surface },
@@ -201,12 +412,18 @@ const S = StyleSheet.create({
201
412
  justifyContent: 'center',
202
413
  marginRight: 12,
203
414
  },
415
+ avatarGroup: {
416
+ backgroundColor: '#0d3328',
417
+ borderColor: C.groupBright,
418
+ },
204
419
  avatarText: { color: C.accentBright, fontSize: 14, fontWeight: '700' },
420
+ avatarTextGroup: { color: C.groupBright, fontSize: 20 },
205
421
 
206
422
  rowBody: { flex: 1 },
207
- rowTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 },
423
+ rowTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3 },
208
424
  rowName: { color: C.text, fontSize: 15, fontWeight: '600', flex: 1, marginRight: 8 },
209
425
  rowTime: { color: C.textDim, fontSize: 12 },
426
+ rowGroupBadge: { color: C.groupBright, fontSize: 10, fontWeight: '700', letterSpacing: 0.5 },
210
427
 
211
428
  rowBottom: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
212
429
  rowPreview: { color: C.textDim, fontSize: 13, flex: 1, marginRight: 8 },
@@ -226,10 +443,14 @@ const S = StyleSheet.create({
226
443
  emptyTitle: { color: C.text, fontSize: 20, fontWeight: '600', marginBottom: 10 },
227
444
  emptyBody: { color: C.textDim, fontSize: 14, textAlign: 'center', lineHeight: 22 },
228
445
 
229
- fab: {
446
+ fabRow: {
230
447
  position: 'absolute',
231
448
  right: 20,
232
449
  bottom: 24,
450
+ flexDirection: 'row',
451
+ gap: 12,
452
+ },
453
+ fab: {
233
454
  width: 54,
234
455
  height: 54,
235
456
  borderRadius: 27,
@@ -242,8 +463,9 @@ const S = StyleSheet.create({
242
463
  shadowRadius: 6,
243
464
  elevation: 6,
244
465
  },
466
+ fabGroup: { backgroundColor: C.group },
245
467
  fabPressed: { opacity: 0.8 },
246
- fabText: { color: '#fff', fontSize: 28, lineHeight: 32, fontWeight: '300' },
468
+ fabText: { color: '#fff', fontSize: 24, lineHeight: 28, fontWeight: '400' },
247
469
 
248
470
  overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', alignItems: 'center', padding: 24 },
249
471
  modal: { width: '100%', backgroundColor: C.surface, borderRadius: 16, borderWidth: 1, borderColor: C.border, padding: 20, gap: 12 },
@@ -260,4 +482,21 @@ const S = StyleSheet.create({
260
482
  modalBtnCancel: { backgroundColor: '#1a2e40' },
261
483
  modalBtnOk: { backgroundColor: C.accent },
262
484
  modalBtnText: { color: '#e8f6ff', fontSize: 14, fontWeight: '600' },
485
+
486
+ tabs: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1, borderColor: C.border },
487
+ tab: { flex: 1, paddingVertical: 8, alignItems: 'center', backgroundColor: '#0b1820' },
488
+ tabActive: { backgroundColor: C.accent },
489
+ tabText: { color: C.textDim, fontSize: 14, fontWeight: '600' },
490
+ tabTextActive: { color: '#fff' },
491
+
492
+ inviteBox: {
493
+ backgroundColor: '#0b1820',
494
+ borderRadius: 10,
495
+ borderWidth: 1,
496
+ borderColor: C.border,
497
+ padding: 12,
498
+ gap: 4,
499
+ },
500
+ inviteLabel: { color: C.textDim, fontSize: 11, fontWeight: '600', letterSpacing: 0.5, textTransform: 'uppercase' },
501
+ inviteValue: { color: C.text, fontSize: 12, fontFamily: 'monospace', marginBottom: 6 },
263
502
  });
@@ -85,15 +85,17 @@ function Bubble({ msg }: Readonly<{ msg: BubbleMsg }>) {
85
85
  export default function ConversationScreen() {
86
86
  const { address } = useLocalSearchParams<{ address: string }>();
87
87
  const router = useRouter();
88
- const { events, send, fetchMessages, markRead, contacts, upsertContact } = useLxmfContext();
88
+ const { events, send, fetchMessages, markRead, contacts, upsertContact, groups, isGroup, shareGroupInvite } = useLxmfContext();
89
89
 
90
90
  const [text, setText] = useState('');
91
91
  const [sending, setSending] = useState(false);
92
92
  const [sendErr, setSendErr] = useState('');
93
93
  const listRef = useRef<FlatList>(null);
94
94
 
95
- const contact = contacts.find(c => c.address === address);
96
- const peerName = contact?.name || shortHex(address ?? '');
95
+ const isGroupThread = isGroup(address ?? '');
96
+ const group = isGroupThread ? groups.find(g => g.addrHex === address) : undefined;
97
+ const contact = !isGroupThread ? contacts.find(c => c.address === address) : undefined;
98
+ const peerName = group?.name ?? contact?.name ?? shortHex(address ?? '');
97
99
 
98
100
  // Mark thread as read on open
99
101
  useEffect(() => {
@@ -167,16 +169,21 @@ export default function ConversationScreen() {
167
169
  if (!trimmed || !address) return;
168
170
  setSending(true);
169
171
  setSendErr('');
170
- const r = await send(address, b64encode(trimmed));
172
+ // send() in context auto-routes: group address → sendGroup, peer address → send
173
+ const r = await send(address, trimmed);
171
174
  setSending(false);
172
175
  if (r >= 0) {
173
176
  setText('');
174
- upsertContact(address, { lastMessage: trimmed });
177
+ if (!isGroupThread) upsertContact(address, { lastMessage: trimmed });
175
178
  loadHistory();
176
179
  } else {
177
180
  setSendErr('Send failed — message queued for retry.');
178
181
  }
179
- }, [text, address, send, upsertContact, loadHistory]);
182
+ }, [text, address, send, isGroupThread, upsertContact, loadHistory]);
183
+
184
+ const onShare = useCallback(() => {
185
+ if (address) shareGroupInvite(address);
186
+ }, [address, shareGroupInvite]);
180
187
 
181
188
  const renderBubble = useCallback(({ item }: { item: BubbleMsg }) => (
182
189
  <Bubble msg={item} />
@@ -196,9 +203,17 @@ export default function ConversationScreen() {
196
203
  <Text style={S.backBtnText}>‹</Text>
197
204
  </Pressable>
198
205
  <View style={S.headerCenter}>
199
- <Text style={S.headerName} numberOfLines={1}>{peerName}</Text>
206
+ <View style={S.headerNameRow}>
207
+ {isGroupThread && <Text style={S.groupHash}>#</Text>}
208
+ <Text style={S.headerName} numberOfLines={1}>{peerName}</Text>
209
+ </View>
200
210
  <Text selectable style={S.headerAddr}>{shortHex(address ?? '')}</Text>
201
211
  </View>
212
+ {isGroupThread && (
213
+ <Pressable style={({ pressed }) => [S.shareBtn, pressed && { opacity: 0.7 }]} onPress={onShare}>
214
+ <Text style={S.shareBtnText}>Share</Text>
215
+ </Pressable>
216
+ )}
202
217
  </View>
203
218
 
204
219
  {/* Message list */}
@@ -218,7 +233,7 @@ export default function ConversationScreen() {
218
233
  <View style={S.compose}>
219
234
  <TextInput
220
235
  style={S.composeInput}
221
- placeholder="Message…"
236
+ placeholder={isGroupThread ? `Message #${peerName}…` : 'Message'}
222
237
  placeholderTextColor="#4a6070"
223
238
  value={text}
224
239
  onChangeText={setText}
@@ -226,7 +241,7 @@ export default function ConversationScreen() {
226
241
  maxLength={2000}
227
242
  />
228
243
  <Pressable
229
- style={({ pressed }) => [S.sendBtn, (!text.trim() || sending) && S.sendBtnDisabled, pressed && { opacity: 0.75 }]}
244
+ style={({ pressed }) => [S.sendBtn, isGroupThread && S.sendBtnGroup, (!text.trim() || sending) && S.sendBtnDisabled, pressed && { opacity: 0.75 }]}
230
245
  onPress={onSend}
231
246
  disabled={!text.trim() || sending}>
232
247
  <Text style={S.sendBtnText}>{sending ? '…' : '↑'}</Text>
@@ -256,8 +271,19 @@ const S = StyleSheet.create({
256
271
  backBtn: { paddingHorizontal: 8, paddingVertical: 4 },
257
272
  backBtnText: { color: '#1a7fc1', fontSize: 30, lineHeight: 34 },
258
273
  headerCenter: { flex: 1 },
259
- headerName: { color: '#d8ecf8', fontSize: 16, fontWeight: '700' },
274
+ headerNameRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
275
+ groupHash: { color: '#3edba8', fontSize: 18, fontWeight: '700', lineHeight: 22 },
276
+ headerName: { color: '#d8ecf8', fontSize: 16, fontWeight: '700', flex: 1 },
260
277
  headerAddr: { color: '#4a6070', fontSize: 11, fontFamily: 'monospace' },
278
+ shareBtn: {
279
+ paddingHorizontal: 10,
280
+ paddingVertical: 6,
281
+ borderRadius: 8,
282
+ backgroundColor: '#1a3328',
283
+ borderWidth: 1,
284
+ borderColor: '#3edba8',
285
+ },
286
+ shareBtnText: { color: '#3edba8', fontSize: 12, fontWeight: '600' },
261
287
 
262
288
  list: { paddingHorizontal: 12, paddingVertical: 12, gap: 6, flexGrow: 1 },
263
289
 
@@ -324,6 +350,7 @@ const S = StyleSheet.create({
324
350
  alignItems: 'center',
325
351
  justifyContent: 'center',
326
352
  },
353
+ sendBtnGroup: { backgroundColor: '#1a8c6a' },
327
354
  sendBtnDisabled: { opacity: 0.35 },
328
355
  sendBtnText: { color: '#fff', fontSize: 20, fontWeight: '700' },
329
356
  });
@@ -143,6 +143,31 @@ func lxmf_beacon_rpc(
143
143
  _ paramsJson: UnsafePointer<CChar>?
144
144
  ) -> Int64
145
145
 
146
+ @_silgen_name("lxmf_create_group")
147
+ func lxmf_create_group(
148
+ _ name: UnsafePointer<CChar>?,
149
+ _ keyHex: UnsafePointer<CChar>?,
150
+ _ outAddrBuf: UnsafeMutablePointer<UInt8>?,
151
+ _ outAddrLen: Int
152
+ ) -> Int32
153
+
154
+ @_silgen_name("lxmf_join_group")
155
+ func lxmf_join_group(
156
+ _ addrHex: UnsafePointer<CChar>?,
157
+ _ keyHex: UnsafePointer<CChar>?
158
+ ) -> Int32
159
+
160
+ @_silgen_name("lxmf_leave_group")
161
+ func lxmf_leave_group(_ addrHex: UnsafePointer<CChar>?) -> Int32
162
+
163
+ @_silgen_name("lxmf_send_group")
164
+ func lxmf_send_group(
165
+ _ addrHex: UnsafePointer<CChar>?,
166
+ _ bodyPtr: UnsafePointer<UInt8>?,
167
+ _ bodyLen: Int,
168
+ _ fieldsJson: UnsafePointer<CChar>?
169
+ ) -> Int64
170
+
146
171
 
147
172
  public class LxmfModule: Module {
148
173
  // Shared JSON buffer for FFI calls (64KB)
@@ -381,6 +406,52 @@ public class LxmfModule: Module {
381
406
  Function("pairNusRNode") { (identifier: String) -> Bool in
382
407
  return self.bleManager.connectRNode(identifier)
383
408
  }
409
+
410
+ // --- Group Chat ---
411
+
412
+ Function("createGroup") { (name: String, keyHex: String) -> String in
413
+ var addrBuf = [UInt8](repeating: 0, count: 33)
414
+ let rc = name.withCString { namePtr in
415
+ keyHex.withCString { keyPtr in
416
+ lxmf_create_group(namePtr, keyPtr, &addrBuf, 33)
417
+ }
418
+ }
419
+ guard rc == 0 else { throw NSError(domain: "LxmfGroup", code: -1) }
420
+ return String(bytes: addrBuf.prefix(while: { $0 != 0 }), encoding: .utf8) ?? ""
421
+ }
422
+
423
+ Function("joinGroup") { (addrHex: String, keyHex: String) -> Bool in
424
+ let rc = addrHex.withCString { addrPtr in
425
+ keyHex.withCString { keyPtr in
426
+ lxmf_join_group(addrPtr, keyPtr)
427
+ }
428
+ }
429
+ return rc == 0
430
+ }
431
+
432
+ Function("leaveGroup") { (addrHex: String) -> Bool in
433
+ let rc = addrHex.withCString { lxmf_leave_group($0) }
434
+ return rc == 0
435
+ }
436
+
437
+ AsyncFunction("sendGroup") { (addrHex: String, bodyBase64: String, fieldsJson: String?) -> Double in
438
+ guard let bodyData = Data(base64Encoded: bodyBase64) else {
439
+ throw NSError(domain: "LxmfGroup", code: -2, userInfo: [NSLocalizedDescriptionKey: "invalid base64 body"])
440
+ }
441
+ let seq = addrHex.withCString { addrPtr in
442
+ bodyData.withUnsafeBytes { bodyBuf -> Int64 in
443
+ guard let bodyPtr = bodyBuf.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return -1 }
444
+ if let fields = fieldsJson {
445
+ return fields.withCString { fieldsPtr in
446
+ lxmf_send_group(addrPtr, bodyPtr, bodyData.count, fieldsPtr)
447
+ }
448
+ }
449
+ return lxmf_send_group(addrPtr, bodyPtr, bodyData.count, nil)
450
+ }
451
+ }
452
+ if seq < 0 { throw NSError(domain: "LxmfGroup", code: -3) }
453
+ return Double(seq)
454
+ }
384
455
  }
385
456
 
386
457
  // MARK: - Polling
@@ -8,32 +8,32 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>liblxmf_rn.a</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64_x86_64-simulator</string>
11
+ <string>ios-arm64</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>liblxmf_rn.a</string>
14
14
  <key>SupportedArchitectures</key>
15
15
  <array>
16
16
  <string>arm64</string>
17
- <string>x86_64</string>
18
17
  </array>
19
18
  <key>SupportedPlatform</key>
20
19
  <string>ios</string>
21
- <key>SupportedPlatformVariant</key>
22
- <string>simulator</string>
23
20
  </dict>
24
21
  <dict>
25
22
  <key>BinaryPath</key>
26
23
  <string>liblxmf_rn.a</string>
27
24
  <key>LibraryIdentifier</key>
28
- <string>ios-arm64</string>
25
+ <string>ios-arm64_x86_64-simulator</string>
29
26
  <key>LibraryPath</key>
30
27
  <string>liblxmf_rn.a</string>
31
28
  <key>SupportedArchitectures</key>
32
29
  <array>
33
30
  <string>arm64</string>
31
+ <string>x86_64</string>
34
32
  </array>
35
33
  <key>SupportedPlatform</key>
36
34
  <string>ios</string>
35
+ <key>SupportedPlatformVariant</key>
36
+ <string>simulator</string>
37
37
  </dict>
38
38
  </array>
39
39
  <key>CFBundlePackageType</key>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicred-1/react-native-lxmf",
3
- "version": "0.2.44",
3
+ "version": "0.2.47",
4
4
  "description": "LXMF Reticulum mesh networking for React Native + Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",