@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.
package/app.plugin.js CHANGED
@@ -23,7 +23,7 @@ function withLxmfPermissions(config) {
23
23
  return c;
24
24
  });
25
25
 
26
- // iOS BLE usage descriptions
26
+ // iOS BLE usage descriptions + background modes required for background scanning/advertising
27
27
  config = withInfoPlist(config, (c) => {
28
28
  c.modResults.NSBluetoothAlwaysUsageDescription =
29
29
  c.modResults.NSBluetoothAlwaysUsageDescription ||
@@ -31,6 +31,13 @@ function withLxmfPermissions(config) {
31
31
  c.modResults.NSBluetoothPeripheralUsageDescription =
32
32
  c.modResults.NSBluetoothPeripheralUsageDescription ||
33
33
  'Used for LXMF mesh networking via BLE';
34
+ // Required for CoreBluetooth state restoration and background BLE operation.
35
+ // Without these entries the OS terminates BLE scanning/advertising when the
36
+ // app goes to background, breaking the Reticulum mesh.
37
+ const bg = c.modResults.UIBackgroundModes || [];
38
+ if (!bg.includes('bluetooth-central')) bg.push('bluetooth-central');
39
+ if (!bg.includes('bluetooth-peripheral')) bg.push('bluetooth-peripheral');
40
+ c.modResults.UIBackgroundModes = bg;
34
41
  return c;
35
42
  });
36
43
 
@@ -1,27 +1,42 @@
1
1
  import { Tabs } from 'expo-router';
2
- import React from 'react';
3
-
4
2
  import { HapticTab } from '@/components/haptic-tab';
5
3
  import { IconSymbol } from '@/components/ui/icon-symbol';
6
- import { Colors } from '@/constants/theme';
7
- import { useColorScheme } from '@/hooks/use-color-scheme';
8
4
 
9
- export default function TabLayout() {
10
- const colorScheme = useColorScheme();
5
+ const ACCENT = '#1a7fc1';
6
+ const BG = '#0c1218';
7
+ const BORDER = '#1e3040';
8
+ const DIM = '#4a6070';
11
9
 
10
+ const iconConversations = ({ color }: { color: string }) => <IconSymbol size={26} name="message.fill" color={color} />;
11
+ const iconNetwork = ({ color }: { color: string }) => <IconSymbol size={26} name="wifi" color={color} />;
12
+ const iconSettings = ({ color }: { color: string }) => <IconSymbol size={26} name="gearshape.fill" color={color} />;
13
+
14
+ export default function TabLayout() {
12
15
  return (
13
16
  <Tabs
14
17
  screenOptions={{
15
- tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
16
18
  headerShown: false,
17
19
  tabBarButton: HapticTab,
20
+ tabBarActiveTintColor: ACCENT,
21
+ tabBarInactiveTintColor: DIM,
22
+ tabBarStyle: {
23
+ backgroundColor: BG,
24
+ borderTopColor: BORDER,
25
+ borderTopWidth: 1,
26
+ },
27
+ tabBarLabelStyle: { fontSize: 11, fontWeight: '600' },
18
28
  }}>
19
29
  <Tabs.Screen
20
- name="index"
21
- options={{
22
- title: 'Home',
23
- tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
24
- }}
30
+ name="conversations"
31
+ options={{ title: 'Messages', tabBarIcon: iconConversations }}
32
+ />
33
+ <Tabs.Screen
34
+ name="network"
35
+ options={{ title: 'Network', tabBarIcon: iconNetwork }}
36
+ />
37
+ <Tabs.Screen
38
+ name="settings"
39
+ options={{ title: 'Settings', tabBarIcon: iconSettings }}
25
40
  />
26
41
  </Tabs>
27
42
  );
@@ -0,0 +1,263 @@
1
+ import { useCallback, useState } from 'react';
2
+ import {
3
+ FlatList,
4
+ Modal,
5
+ Pressable,
6
+ StyleSheet,
7
+ Text,
8
+ TextInput,
9
+ View,
10
+ } from 'react-native';
11
+ import { useRouter } from 'expo-router';
12
+ import { useLxmfContext, type Contact } from '@/context/LxmfContext';
13
+
14
+ // ── Helpers ───────────────────────────────────────────────────────────────────
15
+
16
+ function shortHex(v: string): string {
17
+ if (!v || v.length <= 12) return v || '—';
18
+ return `${v.slice(0, 6)}…${v.slice(-6)}`;
19
+ }
20
+
21
+ function relTime(unix: number): string {
22
+ const diff = Math.floor(Date.now() / 1000) - unix;
23
+ if (diff < 60) return 'now';
24
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
25
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
26
+ return `${Math.floor(diff / 86400)}d ago`;
27
+ }
28
+
29
+ // ── Contact row ───────────────────────────────────────────────────────────────
30
+
31
+ function ContactRow({ contact, onPress }: Readonly<{ contact: Contact; onPress: () => void }>) {
32
+ const label = contact.name || shortHex(contact.address);
33
+ return (
34
+ <Pressable
35
+ style={({ pressed }) => [S.row, pressed && S.rowPressed]}
36
+ onPress={onPress}>
37
+ <View style={S.avatar}>
38
+ <Text style={S.avatarText}>{label.slice(0, 2).toUpperCase()}</Text>
39
+ </View>
40
+ <View style={S.rowBody}>
41
+ <View style={S.rowTop}>
42
+ <Text style={S.rowName} numberOfLines={1}>{label}</Text>
43
+ <Text style={S.rowTime}>{relTime(contact.lastSeen)}</Text>
44
+ </View>
45
+ <View style={S.rowBottom}>
46
+ <Text style={S.rowPreview} numberOfLines={1}>
47
+ {contact.lastMessage || 'No messages yet'}
48
+ </Text>
49
+ {contact.unread > 0 && (
50
+ <View style={S.badge}>
51
+ <Text style={S.badgeText}>{contact.unread > 99 ? '99+' : contact.unread}</Text>
52
+ </View>
53
+ )}
54
+ </View>
55
+ </View>
56
+ </Pressable>
57
+ );
58
+ }
59
+
60
+ // ── Main screen ───────────────────────────────────────────────────────────────
61
+
62
+ export default function ConversationsScreen() {
63
+ const { contacts, upsertContact, isRunning } = useLxmfContext();
64
+ const router = useRouter();
65
+ const [showNew, setShowNew] = useState(false);
66
+ const [newAddr, setNewAddr] = useState('');
67
+ const [addrError, setAddrError] = useState('');
68
+
69
+ const openThread = useCallback((address: string) => {
70
+ router.push(`/conversation/${address}`);
71
+ }, [router]);
72
+
73
+ const addContact = useCallback(() => {
74
+ const addr = newAddr.trim().toLowerCase();
75
+ if (!/^[0-9a-f]{32}$/.test(addr)) {
76
+ setAddrError('Must be 32 hex characters.');
77
+ return;
78
+ }
79
+ upsertContact(addr);
80
+ setShowNew(false);
81
+ setNewAddr('');
82
+ setAddrError('');
83
+ openThread(addr);
84
+ }, [newAddr, upsertContact, openThread]);
85
+
86
+ const renderItem = useCallback(({ item }: { item: Contact }) => (
87
+ <ContactRow contact={item} onPress={() => openThread(item.address)} />
88
+ ), [openThread]);
89
+
90
+ const keyExtractor = useCallback((item: Contact) => item.address, []);
91
+
92
+ return (
93
+ <View style={S.root}>
94
+ <View style={S.header}>
95
+ <Text style={S.headerTitle}>Messages</Text>
96
+ {!isRunning && (
97
+ <Text style={S.headerHint}>Start node in Network tab to receive messages.</Text>
98
+ )}
99
+ </View>
100
+
101
+ {contacts.length === 0 ? (
102
+ <View style={S.empty}>
103
+ <Text style={S.emptyTitle}>No contacts yet</Text>
104
+ <Text style={S.emptyBody}>
105
+ Peer announces appear here automatically.{'\n'}
106
+ Tap + to message a known address.
107
+ </Text>
108
+ </View>
109
+ ) : (
110
+ <FlatList
111
+ data={contacts}
112
+ keyExtractor={keyExtractor}
113
+ renderItem={renderItem}
114
+ contentContainerStyle={S.list}
115
+ ItemSeparatorComponent={Separator}
116
+ />
117
+ )}
118
+
119
+ {/* FAB */}
120
+ <Pressable style={({ pressed }) => [S.fab, pressed && S.fabPressed]} onPress={() => setShowNew(true)}>
121
+ <Text style={S.fabText}>+</Text>
122
+ </Pressable>
123
+
124
+ {/* New conversation modal */}
125
+ <Modal visible={showNew} transparent animationType="fade" onRequestClose={() => setShowNew(false)}>
126
+ <Pressable style={S.overlay} onPress={() => setShowNew(false)}>
127
+ <Pressable style={S.modal}>
128
+ <Text style={S.modalTitle}>New Conversation</Text>
129
+ <Text style={S.modalHint}>Enter 32-character LXMF address (hex)</Text>
130
+ <TextInput
131
+ style={S.modalInput}
132
+ placeholder="aabbccdd…"
133
+ placeholderTextColor="#4a6070"
134
+ value={newAddr}
135
+ onChangeText={t => { setNewAddr(t); setAddrError(''); }}
136
+ autoCapitalize="none"
137
+ autoCorrect={false}
138
+ autoFocus
139
+ />
140
+ {addrError ? <Text style={S.modalError}>{addrError}</Text> : null}
141
+ <View style={S.modalBtns}>
142
+ <Pressable style={[S.modalBtn, S.modalBtnCancel]} onPress={() => { setShowNew(false); setNewAddr(''); setAddrError(''); }}>
143
+ <Text style={S.modalBtnText}>Cancel</Text>
144
+ </Pressable>
145
+ <Pressable style={[S.modalBtn, S.modalBtnOk]} onPress={addContact}>
146
+ <Text style={S.modalBtnText}>Open</Text>
147
+ </Pressable>
148
+ </View>
149
+ </Pressable>
150
+ </Pressable>
151
+ </Modal>
152
+ </View>
153
+ );
154
+ }
155
+
156
+ function Separator() {
157
+ return <View style={S.separator} />;
158
+ }
159
+
160
+ // ── Styles ────────────────────────────────────────────────────────────────────
161
+
162
+ const C = {
163
+ bg: '#0c1218',
164
+ surface: '#131d26',
165
+ border: '#1e3040',
166
+ accent: '#1a7fc1',
167
+ accentBright: '#4fb3e8',
168
+ text: '#d8ecf8',
169
+ textDim: '#7a9db5',
170
+ warn: '#f0a500',
171
+ };
172
+
173
+ const S = StyleSheet.create({
174
+ root: { flex: 1, backgroundColor: C.bg },
175
+
176
+ header: {
177
+ paddingHorizontal: 16,
178
+ paddingTop: 56,
179
+ paddingBottom: 12,
180
+ backgroundColor: C.surface,
181
+ borderBottomWidth: 1,
182
+ borderBottomColor: C.border,
183
+ },
184
+ headerTitle: { color: C.text, fontSize: 28, fontWeight: '700' },
185
+ headerHint: { color: C.warn, fontSize: 12, marginTop: 4 },
186
+
187
+ list: { paddingBottom: 80 },
188
+ separator: { height: 1, backgroundColor: C.border, marginLeft: 72 },
189
+
190
+ row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, backgroundColor: C.surface },
191
+ rowPressed: { backgroundColor: '#17232e' },
192
+
193
+ avatar: {
194
+ width: 44,
195
+ height: 44,
196
+ borderRadius: 22,
197
+ backgroundColor: '#0d3550',
198
+ borderWidth: 1,
199
+ borderColor: C.accentBright,
200
+ alignItems: 'center',
201
+ justifyContent: 'center',
202
+ marginRight: 12,
203
+ },
204
+ avatarText: { color: C.accentBright, fontSize: 14, fontWeight: '700' },
205
+
206
+ rowBody: { flex: 1 },
207
+ rowTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 },
208
+ rowName: { color: C.text, fontSize: 15, fontWeight: '600', flex: 1, marginRight: 8 },
209
+ rowTime: { color: C.textDim, fontSize: 12 },
210
+
211
+ rowBottom: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
212
+ rowPreview: { color: C.textDim, fontSize: 13, flex: 1, marginRight: 8 },
213
+
214
+ badge: {
215
+ backgroundColor: C.accent,
216
+ borderRadius: 10,
217
+ minWidth: 20,
218
+ height: 20,
219
+ alignItems: 'center',
220
+ justifyContent: 'center',
221
+ paddingHorizontal: 5,
222
+ },
223
+ badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
224
+
225
+ empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 40 },
226
+ emptyTitle: { color: C.text, fontSize: 20, fontWeight: '600', marginBottom: 10 },
227
+ emptyBody: { color: C.textDim, fontSize: 14, textAlign: 'center', lineHeight: 22 },
228
+
229
+ fab: {
230
+ position: 'absolute',
231
+ right: 20,
232
+ bottom: 24,
233
+ width: 54,
234
+ height: 54,
235
+ borderRadius: 27,
236
+ backgroundColor: C.accent,
237
+ alignItems: 'center',
238
+ justifyContent: 'center',
239
+ shadowColor: '#000',
240
+ shadowOffset: { width: 0, height: 3 },
241
+ shadowOpacity: 0.4,
242
+ shadowRadius: 6,
243
+ elevation: 6,
244
+ },
245
+ fabPressed: { opacity: 0.8 },
246
+ fabText: { color: '#fff', fontSize: 28, lineHeight: 32, fontWeight: '300' },
247
+
248
+ overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', alignItems: 'center', padding: 24 },
249
+ modal: { width: '100%', backgroundColor: C.surface, borderRadius: 16, borderWidth: 1, borderColor: C.border, padding: 20, gap: 12 },
250
+ modalTitle: { color: C.text, fontSize: 18, fontWeight: '700' },
251
+ modalHint: { color: C.textDim, fontSize: 13 },
252
+ modalInput: {
253
+ borderWidth: 1, borderColor: '#2a4050', backgroundColor: '#0b1820', color: C.text,
254
+ borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10,
255
+ fontFamily: 'monospace', fontSize: 13,
256
+ },
257
+ modalError: { color: '#ff7070', fontSize: 12 },
258
+ modalBtns: { flexDirection: 'row', gap: 10, marginTop: 4 },
259
+ modalBtn: { flex: 1, borderRadius: 10, paddingVertical: 11, alignItems: 'center' },
260
+ modalBtnCancel: { backgroundColor: '#1a2e40' },
261
+ modalBtnOk: { backgroundColor: C.accent },
262
+ modalBtnText: { color: '#e8f6ff', fontSize: 14, fontWeight: '600' },
263
+ });