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