import React, { useEffect, useState } from "react"; import { Platform, Pressable, TextInput, View } from "react-native"; import Animated, { cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from "react-native-reanimated"; import { Ionicons } from "@expo/vector-icons"; import { useColorScheme } from "nativewind"; import { Dialog, DialogContent, DialogFooter } from "./dialog"; import { Text } from "./text"; import AuthService from "../services/auth.service"; import { getDefaultBaseUrl } from "../services/api-config"; import { useAppConfig } from "../context/AppConfigContext"; const HOVER_COLOR = "#22C55E"; export interface ServerSettingsProps { /** * Health-check path appended to the entered server URL's origin. * Default `/api/health`. */ healthCheckPath?: string; /** * Validator the test step runs against the parsed JSON body of the * health response. Default: requires `{ status, version }` (matches * Entropy Softworks servers). Return `false` to mark the test failed. */ validateHealthResponse?: (body: unknown) => boolean; /** Error message shown when validation fails. Default uses `appName`. */ validationFailureMessage?: string; /** Override for the placeholder shown in the URL input. */ urlPlaceholder?: string; } /** * Settings gear icon that opens the server-configuration dialog. * Renders as an absolute-positioned button in the top-right corner. * Use on any auth screen to give users a self-hosted-server entry * point. */ export function ServerSettings({ healthCheckPath = "/api/health", validateHealthResponse, validationFailureMessage, urlPlaceholder = "https://example.com", }: ServerSettingsProps = {}) { const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const { appName } = useAppConfig(); const [visible, setVisible] = useState(false); const [serverUrl, setServerUrl] = useState(""); const [testingServer, setTestingServer] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string; } | null>(null); const [retryCount, setRetryCount] = useState(0); const isOverridden = serverUrl !== getDefaultBaseUrl(); const progressAnimation = useSharedValue(0); const shimmerAnimation = useSharedValue(0); useEffect(() => { if (visible) { setServerUrl(AuthService.getBaseUrl()); setTestResult(null); } }, [visible]); useEffect(() => { if (testingServer) { progressAnimation.value = 0; shimmerAnimation.value = 0; shimmerAnimation.value = withRepeat( withTiming(1, { duration: 500, easing: Easing.linear }), -1 ); } else { cancelAnimation(shimmerAnimation); shimmerAnimation.value = 0; } }, [testingServer, progressAnimation, shimmerAnimation]); const animatedProgressStyle = useAnimatedStyle(() => ({ width: `${progressAnimation.value}%`, })); const animatedShimmerStyle = useAnimatedStyle(() => ({ transform: [{ translateX: -50 + shimmerAnimation.value * 350 }], })); const defaultValidate = (body: unknown): boolean => { if (!body || typeof body !== "object") { return false; } const obj = body as Record; return Boolean(obj.status) && Boolean(obj.version); }; const validate = validateHealthResponse ?? defaultValidate; const invalidServerMessage = validationFailureMessage ?? `Not a valid ${appName} server`; const handleTest = async () => { setTestingServer(true); setTestResult(null); setRetryCount(0); progressAnimation.value = 0; const urlRegex = /^https?:\/\/[\w-]+(\.[\w-]+)*(:\d+)?(\/.*)?$/; if (!urlRegex.test(serverUrl)) { setTestResult({ success: false, message: "Invalid URL format. Please use http:// or https://", }); setTestingServer(false); return; } let url: URL; try { url = new URL(serverUrl); } catch { setTestResult({ success: false, message: "Invalid URL format" }); setTestingServer(false); return; } const maxRetries = 3; const retryDelay = 2000; let attemptCount = 0; let lastError: unknown = null; const attemptConnection = async (): Promise => { attemptCount++; setRetryCount(attemptCount); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); try { const healthUrl = `${url.origin}${healthCheckPath}`; const response = await fetch(healthUrl, { method: "GET", signal: controller.signal, headers: { Accept: "application/json" }, }); if (response.ok) { const data: unknown = await response.json(); if (!validate(data)) { throw new Error(invalidServerMessage); } return true; } return false; } catch (error: unknown) { lastError = error; return false; } finally { clearTimeout(timeoutId); progressAnimation.value = withTiming((attemptCount / maxRetries) * 100, { duration: 300, easing: Easing.out(Easing.cubic), }); } }; let success = false; for (let i = 0; i < maxRetries; i++) { if (i > 0) { await new Promise((resolve) => setTimeout(resolve, retryDelay)); } success = await attemptConnection(); if (success) { break; } } if (success) { setTestResult({ success: true, message: "Server is reachable!" }); } else if (lastError instanceof Error && lastError.name === "AbortError") { setTestResult({ success: false, message: `Connection timeout after ${maxRetries} attempts`, }); } else { setTestResult({ success: false, message: `Cannot reach server after ${maxRetries} attempts`, }); } setTestingServer(false); setRetryCount(0); }; const handleSave = async () => { try { await AuthService.setBaseUrl(serverUrl); setVisible(false); } catch (e) { const msg = e instanceof Error ? e.message : "Invalid server URL"; setTestResult({ success: false, message: msg }); } }; const handleReset = async () => { await AuthService.resetBaseUrl(); setServerUrl(getDefaultBaseUrl()); setTestResult(null); }; const close = () => setVisible(false); return ( <> setVisible(true)} style={{ position: "absolute", top: 16, right: 24, zIndex: 10 }} accessibilityLabel="Server settings" accessibilityRole="button" > {({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => { const active = pressed || hovered; return ( ); }} Configure your self-hosted server URL {(testingServer || testResult) && ( {testingServer ? `Testing server connection... (Attempt ${retryCount}/3)` : testResult?.message} {testingServer && ( )} )} {isOverridden && } ); } /** Reusable dialog button with green hover effect, used by the ServerSettings footer. */ function ServerSettingsButton({ label, onPress, disabled, filled, }: { label: string; onPress: () => void; disabled?: boolean; filled?: boolean; }) { const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; return ( {({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => { const active = pressed || hovered; const defaultBg = filled ? (isDark ? "#FFFFFF" : "#000000") : "transparent"; const defaultText = filled ? isDark ? "#000000" : "#FFFFFF" : isDark ? "#FFFFFF" : "#000000"; return ( {label} ); }} ); } export default ServerSettings;