@magicred-1/react-native-lxmf 0.2.33 → 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/ios/RustCore/liblxmf_rn.xcframework/Info.plist +5 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
10
|
-
|
|
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="
|
|
21
|
-
options={{
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
});
|