Files
libp2p-native-bridge/implementations/SettingsUI.tsx
Chris Daßler 6f1d6ec37b Initial commit: libp2p-native-bridge package
- Extracted libp2p component from main app
- Created modular package structure with interfaces and implementations
- Added dependency injection for NativeModules
- Configured for IOR loading from Gitea
- Added comprehensive README and documentation
2025-08-29 11:18:37 +02:00

497 lines
15 KiB
TypeScript

/**
* Settings UI Component
*
* Provides configuration options for the libp2p node including:
* - Port configuration
* - Network settings
* - Data management (reset app)
* - Debug options
*/
import { LoggerComponent } from 'ior:gitea:gitea.metatrom.net:universal-components/logger@1.0.0';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
NativeModules,
Platform,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
// SecureStorage removed - not needed in external package
import { DEFAULT_SETTINGS, type AppSettings } from './SettingsService';
const { Libp2pModule, DiscoveryModule, SecureStorageModule } = NativeModules;
// Settings storage keys
const SETTINGS_STORAGE_KEY = '@libp2p_settings';
// Use AppSettings from SettingsService
// The LibP2PSettings type is now replaced by AppSettings
interface SettingsUIProps {
initialSettings?: AppSettings;
onSettingsChange?: (settings: AppSettings) => void;
onPortsSaved?: () => void;
onReset?: () => void;
}
export const SettingsUI: React.FC<SettingsUIProps> = ({
initialSettings,
onSettingsChange,
onPortsSaved,
onReset,
}) => {
const loggerRef = useRef<LoggerComponent | null>(null);
if (!loggerRef.current) {
loggerRef.current = new LoggerComponent('SettingsUI');
}
const logger = loggerRef.current;
const [settings, setSettings] = useState<AppSettings>(initialSettings || DEFAULT_SETTINGS);
const [isDirty, setIsDirty] = useState(false);
const [tcpPortInput, setTcpPortInput] = useState(
String((initialSettings || DEFAULT_SETTINGS).tcpPort),
);
const [wsPortInput, setWsPortInput] = useState(
String((initialSettings || DEFAULT_SETTINGS).wsPort),
);
const [discoveryTimeoutInput, setDiscoveryTimeoutInput] = useState(
String((initialSettings || DEFAULT_SETTINGS).discoveryTimeout / 1000),
);
const [dhtServerUrlInput, setDhtServerUrlInput] = useState(
(initialSettings || DEFAULT_SETTINGS).dhtServerUrl,
);
// Update state when initialSettings changes
useEffect(() => {
if (initialSettings) {
setSettings(initialSettings);
setTcpPortInput(String(initialSettings.tcpPort));
setWsPortInput(String(initialSettings.wsPort));
setDiscoveryTimeoutInput(String(initialSettings.discoveryTimeout / 1000));
setDhtServerUrlInput(initialSettings.dhtServerUrl);
} else {
loadSettings();
}
}, [initialSettings]);
/**
* Load settings from AsyncStorage
*/
const loadSettings = async () => {
try {
const stored = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
let loadedSettings: LibP2PSettings;
if (stored) {
const parsed = JSON.parse(stored);
loadedSettings = { ...DEFAULT_SETTINGS, ...parsed };
} else {
// No stored settings, use defaults
loadedSettings = DEFAULT_SETTINGS;
}
setSettings(loadedSettings);
setTcpPortInput(String(loadedSettings.tcpPort));
setWsPortInput(String(loadedSettings.wsPort));
setDiscoveryTimeoutInput(String(loadedSettings.discoveryTimeout / 1000));
setDhtServerUrlInput(loadedSettings.dhtServerUrl);
// Don't call onSettingsChange during initial load
// Parent component should handle initial settings
} catch (error) {
logger.error('[Settings] Failed to load settings:', error);
// On error, still load defaults
setSettings(DEFAULT_SETTINGS);
setTcpPortInput(String(DEFAULT_SETTINGS.tcpPort));
setWsPortInput(String(DEFAULT_SETTINGS.wsPort));
setDiscoveryTimeoutInput(String(DEFAULT_SETTINGS.discoveryTimeout / 1000));
setDhtServerUrlInput(DEFAULT_SETTINGS.dhtServerUrl);
}
};
/**
* Save settings to AsyncStorage
*/
const saveSettings = async () => {
try {
// Validate ports
const tcpPort = Number.parseInt(tcpPortInput, 10);
const wsPort = Number.parseInt(wsPortInput, 10);
const discoveryTimeout = Number.parseInt(discoveryTimeoutInput, 10) * 1000;
if (Number.isNaN(tcpPort) || tcpPort < 1024 || tcpPort > 65535) {
Alert.alert('Invalid TCP Port', 'Please enter a valid port number (1024-65535)');
return;
}
if (Number.isNaN(wsPort) || wsPort < 1024 || wsPort > 65535) {
Alert.alert('Invalid WebSocket Port', 'Please enter a valid port number (1024-65535)');
return;
}
if (Number.isNaN(discoveryTimeout) || discoveryTimeout < 5000 || discoveryTimeout > 300000) {
Alert.alert('Invalid Discovery Timeout', 'Please enter a value between 5 and 300 seconds');
return;
}
const newSettings = {
...settings,
tcpPort,
wsPort,
discoveryTimeout,
dhtServerUrl: dhtServerUrlInput,
};
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
setSettings(newSettings);
setIsDirty(false);
if (onSettingsChange) {
onSettingsChange(newSettings);
}
// Call the onPortsSaved callback to trigger libp2p restart
if (onPortsSaved) {
onPortsSaved();
}
} catch (error) {
logger.error('[Settings] Failed to save settings:', error);
Alert.alert('Error', 'Failed to save settings');
}
};
/**
* Reset all app data
*/
const resetApp = () => {
Alert.alert(
'Reset App Data',
'This will delete all stored data including:\n\n• Your peer identity\n• All settings\n• Stored devices\n• Chat history\n\nThe app will be like freshly installed. Continue?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Reset',
style: 'destructive',
onPress: async () => {
try {
// Show progress alert
Alert.alert('Resetting...', 'Please wait while we clear all data');
// Clear all AsyncStorage
await AsyncStorage.clear();
// Clear all secure storage (identities, paired devices, etc.)
try {
// Clear secure storage if available
if (SecureStorageModule?.clearAll) {
await SecureStorageModule.clearAll();
}
logger.info('[SettingsUI] Cleared all secure storage data');
} catch (error) {
logger.error('[SettingsUI] Failed to clear secure storage:', error);
}
// Clear native module stored data
if (Platform.OS === 'ios') {
// iOS uses UserDefaults
if (Libp2pModule.clearStoredData) {
await Libp2pModule.clearStoredData();
}
if (DiscoveryModule.clearStoredData) {
await DiscoveryModule.clearStoredData();
}
// SecureStorage handled above
} else if (Platform.OS === 'android') {
// Android uses SharedPreferences
if (Libp2pModule.clearStoredData) {
await Libp2pModule.clearStoredData();
}
if (DiscoveryModule.clearStoredData) {
await DiscoveryModule.clearStoredData();
}
if (SecureStorageModule?.clearAll) {
await SecureStorageModule.clearAll();
}
}
// Reset settings to defaults
setSettings(DEFAULT_SETTINGS);
setTcpPortInput(String(DEFAULT_SETTINGS.tcpPort));
setWsPortInput(String(DEFAULT_SETTINGS.wsPort));
setDiscoveryTimeoutInput(String(DEFAULT_SETTINGS.discoveryTimeout / 1000));
setDhtServerUrlInput(DEFAULT_SETTINGS.dhtServerUrl);
if (onReset) {
onReset();
}
Alert.alert(
'Reset Complete',
'All app data has been cleared. Please restart the app for a fresh start.',
[
{
text: 'OK',
onPress: () => {
// Optionally restart the app
// RNRestart.Restart(); // Requires react-native-restart package
},
},
],
);
} catch (error) {
logger.error('[Settings] Failed to reset app:', error);
Alert.alert('Error', 'Failed to reset app data');
}
},
},
],
);
};
const handleToggleCustomPorts = async () => {
const newSettings = { ...settings, useCustomPorts: !settings.useCustomPorts };
setSettings(newSettings);
// Auto-save switch changes immediately
try {
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
if (onSettingsChange) {
onSettingsChange(newSettings);
}
} catch (error) {
logger.error('[Settings] Failed to save setting:', error);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={true}
keyboardShouldPersistTaps="handled"
>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Network Configuration</Text>
<View style={styles.setting}>
<Text style={styles.settingLabel}>Use Custom Ports</Text>
<Switch
value={settings.useCustomPorts}
onValueChange={handleToggleCustomPorts}
trackColor={{ false: '#767577', true: '#2196F3' }}
thumbColor={settings.useCustomPorts ? '#fff' : '#f4f3f4'}
ios_backgroundColor="#3e3e3e"
/>
</View>
{settings.useCustomPorts && (
<>
<View style={styles.inputSetting}>
<Text style={styles.settingLabel}>TCP Port</Text>
<TextInput
style={styles.input}
value={tcpPortInput}
onChangeText={(text) => {
setTcpPortInput(text);
setIsDirty(true);
}}
keyboardType="number-pad"
placeholder="10000"
placeholderTextColor="#666"
maxLength={5}
/>
</View>
<View style={styles.inputSetting}>
<Text style={styles.settingLabel}>WebSocket Port</Text>
<TextInput
style={styles.input}
value={wsPortInput}
onChangeText={(text) => {
setWsPortInput(text);
setIsDirty(true);
}}
keyboardType="number-pad"
placeholder="10005"
placeholderTextColor="#666"
maxLength={5}
/>
</View>
</>
)}
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Discovery Configuration</Text>
<View style={styles.inputSetting}>
<Text style={styles.settingLabel}>Timeout (seconds)</Text>
<TextInput
style={styles.input}
value={discoveryTimeoutInput}
onChangeText={(text) => {
setDiscoveryTimeoutInput(text);
setIsDirty(true);
}}
keyboardType="number-pad"
placeholder="30"
placeholderTextColor="#666"
maxLength={3}
/>
</View>
<View style={styles.inputSetting}>
<Text style={styles.settingLabel}>DHT Server URL</Text>
<TextInput
style={styles.input}
value={dhtServerUrlInput}
onChangeText={(text) => {
setDhtServerUrlInput(text);
setIsDirty(true);
}}
placeholder="ws://192.168.188.40:3000/ws"
placeholderTextColor="#666"
selectionColor="#2196F3"
autoCapitalize="none"
autoCorrect={false}
underlineColorAndroid="transparent"
/>
<Text style={styles.settingDescription}>
WebSocket URL of the DHT server for global peer discovery
</Text>
</View>
</View>
{isDirty && (
<View style={styles.section}>
<TouchableOpacity style={styles.saveButton} onPress={saveSettings}>
<Text style={styles.saveButtonText}>Save Settings</Text>
</TouchableOpacity>
</View>
)}
<View style={[styles.section, styles.lastSection]}>
<TouchableOpacity style={styles.dangerButton} onPress={resetApp}>
<Text style={styles.dangerButtonText}>🗑 Reset All App Data</Text>
</TouchableOpacity>
<Text style={styles.warningText}>
This will delete all stored data and reset the app to its initial state
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'transparent',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingVertical: 20,
paddingBottom: 100, // Increased to ensure all content is visible
},
section: {
paddingHorizontal: 20,
marginBottom: 24,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
marginBottom: 16,
},
setting: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingVertical: 16,
},
settingInfo: {
flex: 1,
marginRight: 12,
},
settingLabel: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
marginBottom: 4,
},
settingDescription: {
fontSize: 12,
color: '#999',
marginTop: 2,
},
inputSetting: {
paddingVertical: 12,
},
input: {
marginTop: 8,
paddingHorizontal: 12,
paddingVertical: Platform.OS === 'ios' ? 10 : 8,
backgroundColor: '#2a2a2a',
borderRadius: 8,
borderWidth: 1,
borderColor: '#333',
fontSize: 14,
color: '#fff',
textAlignVertical: 'center',
minHeight: Platform.OS === 'android' ? 40 : undefined,
},
dangerButton: {
marginTop: 12,
paddingVertical: 12,
paddingHorizontal: 20,
backgroundColor: '#5c1e1e',
borderRadius: 8,
alignItems: 'center',
},
dangerButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#ff9999',
},
saveButton: {
marginTop: 16,
paddingVertical: 12,
paddingHorizontal: 20,
backgroundColor: '#2563eb',
borderRadius: 8,
alignItems: 'center',
},
saveButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
warningText: {
fontSize: 12,
color: '#999',
textAlign: 'center',
marginTop: 8,
fontStyle: 'italic',
},
lastSection: {
marginBottom: 40,
},
});
export default SettingsUI;