import React, { useEffect, useState } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, Text, TextInput, View, } from "react-native"; import { withClickTracking } from "../../services/telemetry"; import { SafeAreaView } from "react-native-safe-area-context"; import { useNavigation } from "../../context/NavigationContext"; import { StatusBar } from "expo-status-bar"; import { Ionicons } from "@expo/vector-icons"; import { useColorScheme } from "nativewind"; import * as Haptics from "expo-haptics"; import AuthService from "../../services/auth.service"; import { getDefaultBaseUrl } from "../../services/api-config"; import { useAppConfig } from "../../context/AppConfigContext"; import { getErrorMessage } from "../../utils/error"; export interface ServerConfigScreenProps { /** * Path appended to the configured URL when running the connection * test. Defaults to `/api/health`. Server is expected to return JSON * with `{ status: "healthy", version: "..." }`. */ healthCheckPath?: string; /** Optional help bullets rendered at the bottom. */ helpBullets?: string[]; /** Default number of retry attempts when testing. Default 3. */ defaultMaxRetries?: number; /** Default request timeout in seconds. Default 30. */ defaultTimeoutSeconds?: number; } export function ServerConfigScreen({ healthCheckPath = "/api/health", helpBullets, defaultMaxRetries = 3, defaultTimeoutSeconds = 30, }: ServerConfigScreenProps = {}) { const router = useNavigation(); const { appName } = useAppConfig(); const defaultUrl = getDefaultBaseUrl(); const [serverUrl, setServerUrl] = useState(""); const [isTestingConnection, setIsTestingConnection] = useState(false); const [connectionStatus, setConnectionStatus] = useState< "idle" | "testing" | "success" | "error" >("idle"); const [testAttempt, setTestAttempt] = useState(0); const [isLoading, setIsLoading] = useState(true); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; useEffect(() => { try { setServerUrl(AuthService.getBaseUrl()); } catch { setServerUrl(defaultUrl); } finally { setIsLoading(false); } }, [defaultUrl]); const testConnectionWithRetry = async ( url: string, attemptNumber: number = 1 ): Promise => { const timeoutMs = defaultTimeoutSeconds * 1000; try { setTestAttempt(attemptNumber); const formattedUrl = url.endsWith("/") ? url.slice(0, -1) : url; const healthUrl = `${formattedUrl}${healthCheckPath}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(healthUrl, { method: "GET", signal: controller.signal, headers: { Accept: "application/json" }, }); clearTimeout(timeoutId); if (response.ok) { const data = await response.json(); if (data.status === "healthy" && data.version) { return true; } } if (attemptNumber < defaultMaxRetries) { await new Promise((resolve) => setTimeout(resolve, attemptNumber * 1000)); return await testConnectionWithRetry(url, attemptNumber + 1); } return false; } catch { if (attemptNumber < defaultMaxRetries) { await new Promise((resolve) => setTimeout(resolve, attemptNumber * 1000)); return await testConnectionWithRetry(url, attemptNumber + 1); } return false; } }; const testConnection = async () => { if (!serverUrl) { Alert.alert("Error", "Please enter a server URL"); return; } setIsTestingConnection(true); setConnectionStatus("testing"); setTestAttempt(0); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); try { const success = await testConnectionWithRetry(serverUrl); setIsTestingConnection(false); if (success) { setConnectionStatus("success"); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Alert.alert( "Success", "Connection successful! Server is reachable and responding correctly.", [{ text: "OK" }] ); } else { setConnectionStatus("error"); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Alert.alert( "Connection Failed", `Unable to reach the server after ${defaultMaxRetries} attempts. Please check the URL and try again.`, [{ text: "OK" }] ); } } catch { setIsTestingConnection(false); setConnectionStatus("error"); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Alert.alert( "Connection Error", "An unexpected error occurred while testing the connection.", [{ text: "OK" }] ); } }; const handleSave = async () => { if (!serverUrl) { Alert.alert("Error", "Please enter a server URL"); return; } const sanitizedUrl = serverUrl.trim(); try { const url = new URL(sanitizedUrl); if (url.protocol === "http:" && !["localhost", "127.0.0.1"].includes(url.hostname)) { Alert.alert( "HTTPS Required", "Non-localhost connections require HTTPS. Please use an https:// URL.", [{ text: "OK" }] ); return; } } catch { // setBaseUrl validates and surfaces the error } try { await AuthService.setBaseUrl(sanitizedUrl); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Alert.alert("Configuration Saved", "Server settings have been saved.", [ { text: "OK", onPress: () => router.back() }, ]); } catch (e) { Alert.alert("Save Failed", `Unable to save the configuration: ${getErrorMessage(e)}`, [ { text: "OK" }, ]); } }; const handleUseDefault = () => { setServerUrl(defaultUrl); setConnectionStatus("idle"); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }; const handleClearConfiguration = () => { Alert.alert( "Clear Configuration", "This will reset all server settings to defaults. Are you sure?", [ { text: "Cancel", style: "cancel" }, { text: "Clear", style: "destructive", onPress: async () => { try { await AuthService.resetBaseUrl(); setServerUrl(defaultUrl); setConnectionStatus("idle"); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Alert.alert("Success", "Configuration has been reset to defaults."); } catch { Alert.alert("Error", "Failed to clear configuration."); } }, }, ] ); }; if (isLoading) { return ( ); } const bullets = helpBullets ?? [ `Default server is hosted at ${defaultUrl}`, "Ensure your server is running and the /api/health endpoint is accessible", "Use HTTPS for secure connections", ]; return ( router.back())} accessibilityRole="button" accessibilityLabel="Go back" className="active:scale-98 h-10 w-10 items-center justify-center rounded-lg bg-secondary dark:bg-neutral-800" > Server Configuration Self-Hosted Server Configure your own {appName} server for complete control over your data. Default server: {defaultUrl} Server URL { setServerUrl(text); setConnectionStatus("idle"); }} placeholder={defaultUrl} placeholderTextColor={isDark ? "rgba(255, 255, 255, 0.5)" : "rgba(0, 0, 0, 0.5)"} autoCapitalize="none" autoCorrect={false} keyboardType="url" accessibilityLabel="Server URL" className="ml-2 flex-1 text-base text-foreground dark:text-white" /> {connectionStatus === "success" && ( )} {connectionStatus === "error" && ( )} {isTestingConnection ? ( Testing Connection {testAttempt > 0 ? ` (Attempt ${testAttempt}/${defaultMaxRetries})` : "..."} ) : ( Test Connection )} Use Default Server Clear All Settings Save Configuration Need Help? {bullets.map((bullet, idx) => ( • {bullet} ))} ); } export default ServerConfigScreen;