import { useCallback, useMemo, useReducer, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { parseConnectionUri, serializeConnectionUri, serializeConnectionUriForStorage, } from "@/utils/daemon-endpoints"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; const FLEX_ONE_STYLE = { flex: 1 } as const; interface DirectConnectionDraft { host: string; port: string; useTls: boolean; password: string; } interface PreparedDirectConnection { uri: string; endpoint: string; useTls: boolean; password?: string; } interface DirectConnectionLabels { hostRequired: string; invalidPort: string; invalidConnection: string; failedToConnect: (endpoint: string) => string; noAdditionalDetails: (detail: string) => string; timedOut: string; refused: string; hostNotFound: string; hostUnreachable: string; tlsError: string; unableToConnect: string; } const styles = StyleSheet.create((theme) => ({ field: { gap: theme.spacing[2], }, label: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, input: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], color: theme.colors.foreground, borderWidth: 1, borderColor: theme.colors.border, }, portRow: { flexDirection: "row", gap: theme.spacing[3], }, hostField: { flex: 1, minWidth: 0, }, portField: { width: 112, }, passwordRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, passwordInput: { flex: 1, minWidth: 0, }, iconButton: { width: 44, height: 44, alignItems: "center", justifyContent: "center", borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface2, borderWidth: 1, borderColor: theme.colors.border, }, checkboxRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], }, checkbox: { width: 22, height: 22, borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", }, checkboxChecked: { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent, }, advancedToggle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], alignSelf: "flex-start", paddingVertical: theme.spacing[1], }, advancedText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, actions: { flexDirection: "row", gap: theme.spacing[3], marginTop: theme.spacing[2], }, helper: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm, }, })); function isIpv6Host(host: string): boolean { return host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); } function buildConnectionUriFromDraft( draft: DirectConnectionDraft, labels: DirectConnectionLabels, ): string { const host = draft.host.trim(); const port = Number(draft.port.trim()); if (!host) { throw new Error(labels.hostRequired); } if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error(labels.invalidPort); } return serializeConnectionUriForStorage({ host, port, isIpv6: isIpv6Host(host), useTls: draft.useTls, ...(draft.password ? { password: draft.password } : {}), }); } function prepareDirectConnection( draft: DirectConnectionDraft, labels: DirectConnectionLabels, ): PreparedDirectConnection { const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels)); const endpoint = parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`; return { uri: serializeConnectionUri(parsed), endpoint, useTls: parsed.useTls, ...(parsed.password ? { password: parsed.password } : {}), }; } function draftFromConnectionUri(uri: string): DirectConnectionDraft { const parsed = parseConnectionUri(uri); return { host: parsed.host, port: String(parsed.port), useTls: parsed.useTls, password: parsed.password ?? "", }; } function normalizeTransportMessage(message: string | null | undefined): string | null { if (!message) return null; const trimmed = message.trim(); if (!trimmed) return null; return trimmed; } function formatTechnicalTransportDetails( details: (string | null)[], labels: DirectConnectionLabels, ): string | null { const unique = Array.from( new Set( details .map((value) => normalizeTransportMessage(value)) .filter((value): value is string => Boolean(value)) .map((value) => value.trim()) .filter((value) => value.length > 0), ), ); if (unique.length === 0) return null; const allGeneric = unique.every((value) => { const lower = value.toLowerCase(); return lower === "transport error" || lower === "transport closed"; }); if (allGeneric) { return labels.noAdditionalDetails(unique[0] ?? ""); } return unique.join(" — "); } function buildConnectionFailureCopy(input: { endpoint: string; error: unknown; labels: DirectConnectionLabels; }): { title: string; detail: string | null; raw: string | null } { const { endpoint, error, labels } = input; const title = labels.failedToConnect(endpoint); const raw = (() => { if (error instanceof DaemonConnectionTestError) { return ( formatTechnicalTransportDetails([error.reason, error.lastError], labels) ?? normalizeTransportMessage(error.message) ); } if (error instanceof Error) { return normalizeTransportMessage(error.message); } return null; })(); const rawLower = raw?.toLowerCase() ?? ""; let detail: string | null = null; if (raw === "Incorrect password" || raw === "Password required") { detail = raw; } else if (rawLower.includes("timed out")) { detail = labels.timedOut; } else if ( rawLower.includes("econnrefused") || rawLower.includes("connection refused") || rawLower.includes("err_connection_refused") ) { detail = labels.refused; } else if (rawLower.includes("enotfound") || rawLower.includes("not found")) { detail = labels.hostNotFound; } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { detail = labels.hostUnreachable; } else if ( rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl") ) { detail = labels.tlsError; } else { detail = labels.unableToConnect; } return { title, detail, raw }; } export interface AddHostModalProps { visible: boolean; onClose: () => void; onCancel?: () => void; onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean; }) => void; } export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const daemons = useHosts(); const { probeAndUpsertDirectConnection } = useHostMutations(); const isMobile = useIsCompactFormFactor(); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const [host, setHost] = useState(""); const [port, setPort] = useState("6767"); const [useTls, setUseTls] = useState(false); const [password, setPassword] = useState(""); const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [advancedUri, setAdvancedUri] = useState(""); const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const clearInput = useCallback(() => { setHost(""); setPort("6767"); setUseTls(false); setPassword(""); setIsPasswordVisible(false); setIsAdvancedOpen(false); setAdvancedUri(""); bumpInputResetKey(); }, []); const connectIcon = useMemo( () => , [theme.colors.accentForeground], ); const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []); const portFieldStyle = useMemo(() => [styles.field, styles.portField], []); const checkboxStyle = useMemo( () => [styles.checkbox, useTls ? styles.checkboxChecked : null], [useTls], ); const passwordInputStyle = useMemo(() => [styles.input, styles.passwordInput], []); const useTlsAccessibilityState = useMemo( () => ({ checked: useTls, disabled: isSaving }), [isSaving, useTls], ); const directConnectionLabels = useMemo( () => ({ hostRequired: t("pairing.direct.errors.hostRequired"), invalidPort: t("pairing.direct.errors.invalidPort"), invalidConnection: t("pairing.direct.errors.invalidConnection"), failedToConnect: (endpoint) => t("pairing.direct.errors.failedToConnect", { endpoint }), noAdditionalDetails: (detail) => t("pairing.direct.errors.noAdditionalDetails", { detail }), timedOut: t("pairing.direct.errors.timedOut"), refused: t("pairing.direct.errors.refused"), hostNotFound: t("pairing.direct.errors.hostNotFound"), hostUnreachable: t("pairing.direct.errors.hostUnreachable"), tlsError: t("pairing.direct.errors.tlsError"), unableToConnect: t("pairing.direct.errors.unableToConnect"), }), [t], ); const header = useMemo(() => ({ title: t("pairing.direct.title") }), [t]); const handleClose = useCallback(() => { if (isSaving) return; clearInput(); setErrorMessage(""); onClose(); }, [isSaving, clearInput, onClose]); const handleCancel = useCallback(() => { if (isSaving) return; clearInput(); setErrorMessage(""); (onCancel ?? onClose)(); }, [isSaving, clearInput, onCancel, onClose]); const handleSave = useCallback(async () => { if (isSaving) return; let connection: PreparedDirectConnection; try { connection = prepareDirectConnection( { host, port, useTls, password }, directConnectionLabels, ); } catch (error) { const message = error instanceof Error ? error.message : directConnectionLabels.invalidConnection; setErrorMessage(message); return; } try { setIsSaving(true); setErrorMessage(""); const { profile, serverId, hostname } = await probeAndUpsertDirectConnection({ endpoint: connection.endpoint, useTls: connection.useTls, ...(connection.password ? { password: connection.password } : {}), }); const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); onSaved?.({ profile, serverId, hostname, isNewHost }); handleClose(); } catch (error) { const { title, detail, raw: rawDetail, } = buildConnectionFailureCopy({ endpoint: connection.uri, error, labels: directConnectionLabels, }); let combined: string; if (rawDetail && detail && rawDetail !== detail) { combined = `${title}\n${detail}\n${t("pairing.direct.errors.details", { detail: rawDetail, })}`; } else if (detail) { combined = `${title}\n${detail}`; } else { combined = title; } setErrorMessage(combined); if (!isMobile) { Alert.alert(t("pairing.direct.errors.failedTitle"), combined); } } finally { setIsSaving(false); } }, [ daemons, directConnectionLabels, handleClose, host, isMobile, isSaving, onSaved, password, port, probeAndUpsertDirectConnection, t, useTls, ]); const handleSubmitEditing = useCallback(() => { void handleSave(); }, [handleSave]); const handleSavePress = useCallback(() => { void handleSave(); }, [handleSave]); const handleToggleUseTls = useCallback(() => { if (isSaving) return; setUseTls((current) => !current); }, [isSaving]); const handleTogglePasswordVisibility = useCallback(() => { setIsPasswordVisible((current) => !current); }, []); const handleToggleAdvanced = useCallback(() => { if (!isAdvancedOpen) { try { setAdvancedUri( buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels), ); } catch { setAdvancedUri(""); } setErrorMessage(""); setIsAdvancedOpen(true); return; } try { const next = draftFromConnectionUri(advancedUri); setHost(next.host); setPort(next.port); setUseTls(next.useTls); setPassword(next.password); setErrorMessage(""); bumpInputResetKey(); } catch { setErrorMessage(""); } setIsAdvancedOpen(false); }, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]); const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight; const PasswordIcon = isPasswordVisible ? EyeOff : Eye; return ( {t("pairing.direct.helper")} {t("pairing.direct.fields.host")} {t("pairing.direct.fields.port")} {useTls ? ( ) : null} {t("pairing.direct.fields.useSsl")} {t("pairing.direct.fields.password")} {t("pairing.direct.advanced.label")} {isAdvancedOpen ? ( ) : null} {errorMessage ? {errorMessage} : null} ); }