@magicred-1/react-native-lxmf 0.2.44 → 0.2.48
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/android/src/main/jniLibs/arm64-v8a/liblxmf_rn.so +0 -0
- package/android/src/main/jniLibs/armeabi-v7a/liblxmf_rn.so +0 -0
- package/android/src/main/jniLibs/x86_64/liblxmf_rn.so +0 -0
- package/android/src/main/kotlin/expo/modules/lxmf/LxmfModule.kt +24 -0
- package/build/LxmfModule.d.ts +4 -0
- package/build/LxmfModule.js +4 -0
- package/build/useLxmf.d.ts +4 -0
- package/build/useLxmf.js +45 -0
- package/example/app/(tabs)/conversations.tsx +257 -18
- package/example/app/conversation/[address].tsx +37 -10
- package/ios/LxmfModule.swift +71 -0
- package/ios/RustCore/liblxmf_rn.xcframework/Info.plist +5 -5
- package/ios/RustCore/liblxmf_rn.xcframework/ios-arm64/liblxmf_rn.a +0 -0
- package/ios/RustCore/liblxmf_rn.xcframework/ios-arm64_x86_64-simulator/liblxmf_rn.a +0 -0
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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)
|
package/build/LxmfModule.d.ts
CHANGED
|
@@ -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;
|
package/build/LxmfModule.js
CHANGED
|
@@ -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;
|
package/build/useLxmf.d.ts
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
{
|
|
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={
|
|
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
|
-
<
|
|
121
|
-
<
|
|
122
|
-
|
|
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
|
|
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:
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
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
|
|
96
|
-
const
|
|
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
|
-
|
|
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
|
-
<
|
|
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=
|
|
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
|
-
|
|
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
|
});
|
package/ios/LxmfModule.swift
CHANGED
|
@@ -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-
|
|
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-
|
|
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>
|
|
Binary file
|
|
Binary file
|