import { useEffect, useState } from "react" import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, useColorScheme, ActivityIndicator, Alert, Linking, } from "react-native" import { router } from "expo-router" import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" import { useConnections } from "../../src/stores/connections" import type { ConnectionType } from "../../src/lib/types" import { probeConnection, shareReport } from "../../src/lib/diagnostics" import { captureDiagnostic } from "../../src/lib/sentry" import { parseUrl } from "../../src/lib/diagnostics-classify" import { buildAuth } from "../../src/lib/auth" import { AnalyticsEvent, track } from "../../src/lib/analytics" import { submitWaitlistSignup, buildWaitlistMailtoUrl, needsManualEscapeHatch, type QueuedSignup } from "../../src/lib/waitlist" import { flushPendingSignups, queuePendingSignup, readPendingSignups, dropPendingSignup } from "../../src/lib/waitlist-queue-storage" import appJson from "../../app.json" // Same read as sentry.ts: app.json is the single source of the user-visible // version (package.json/gradle are kept in parity by `npm run check:versions`). const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown" export default function AddConnectionScreen() { const colorScheme = useColorScheme() const isDark = colorScheme === "dark" const { t } = useTranslation() const { addConnection, testConnection } = useConnections() const [mode, setMode] = useState<"quick" | "advanced">("quick") const [type, setType] = useState("local") const [name, setName] = useState("") const [ip, setIp] = useState("") const [port, setPort] = useState("4096") const [url, setUrl] = useState("") const [directory, setDirectory] = useState("") const [username, setUsername] = useState("") const [password, setPassword] = useState("") const [isConnecting, setIsConnecting] = useState(false) const [waitlistEmail, setWaitlistEmail] = useState("") // "queued" = the POST failed but the signup is persisted on-device and will // be retried on the next foreground/connectivity (AGE-87). It is NOT "sent". const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined" | "queued">("idle") const [pendingSignup, setPendingSignup] = useState(null) // Retry anything left over from a previous session as soon as this screen // opens (the root layout also flushes on every foreground), then reflect the // real state back to the user instead of pretending nothing is pending. useEffect(() => { let cancelled = false void (async () => { const outcome = await flushPendingSignups().catch(() => null) if (cancelled) return const pending = outcome ? outcome.pending : await readPendingSignups().catch(() => []) if (cancelled) return if (outcome && outcome.synced.length > 0 && pending.length === 0) { setWaitlistState("joined") return } if (pending.length > 0) { setPendingSignup(pending[pending.length - 1]) setWaitlistState((current) => (current === "idle" ? "queued" : current)) } })() return () => { cancelled = true } }, []) const buildUrl = () => { if (mode === "advanced") return url.trim() const raw = ip.trim() if (!raw) return "" // Be forgiving about pasted values: a full URL, a host:port, or a // host with a trailing path. Extract scheme, host, and port so we // never produce "http://http://host:4096:4096". const schemeMatch = raw.match(/^(https?):\/\//i) const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : "http" let rest = raw.replace(/^https?:\/\//i, "") rest = rest.split("/")[0] // drop any path/query let host = rest let pastedPort = "" const lastColon = rest.lastIndexOf(":") // Only treat trailing ":NNNN" as a port (ignore IPv6 colons / bare host) if (lastColon > -1 && /^\d+$/.test(rest.slice(lastColon + 1))) { host = rest.slice(0, lastColon) pastedPort = rest.slice(lastColon + 1) } const finalPort = pastedPort || port.trim() || "4096" return `${scheme}://${host}:${finalPort}` } const handleQuickConnect = async () => { const serverUrl = buildUrl() if (!serverUrl) { Alert.alert(t("common.error"), t("connection.add.alerts.enterIp")) return } track(AnalyticsEvent.ConnectionFormSubmitted, { mode: "quick" }) setIsConnecting(true) // Test connection first. Quick Connect has no username field, so the // connection is intentionally saved without one — buildAuth() defaults // it to "opencode" wherever auth is built. Sending the `username` state // here would leak a value typed earlier in Advanced mode (issue: Back to // Quick silently overriding the default). const result = await testConnection( { id: "", name: name || t("connection.shared.namePlaceholder"), type: "local", url: serverUrl, }, "onboarding", password || undefined, ) if (result.ok) { // Save and go back try { await addConnection( { name: name.trim() || t("connection.shared.namePlaceholder"), type: "local", url: serverUrl, }, password || undefined, ) setIsConnecting(false) router.back() } catch { setIsConnecting(false) Alert.alert( t("connection.shared.alerts.saveFailedTitle"), t("connection.shared.alerts.saveFailedMessage"), ) } } else { // Failed: run active diagnostics, capture to Sentry, offer a shareable report. const report = await probeConnection(serverUrl, buildAuth(undefined, password)) captureDiagnostic(report) setIsConnecting(false) Alert.alert( t("connection.shared.alerts.connectionFailedTitle"), t("connection.add.alerts.connectionFailedMessage", { summary: report.summary, target: serverUrl, error: result.error || t("connection.shared.alerts.unknownError"), }), [ { text: t("common.ok"), style: "cancel" }, { text: t("common.shareReport"), onPress: () => shareReport(report) }, ], ) } } const handleAdvancedSave = async () => { if (!name.trim()) { Alert.alert(t("common.error"), t("connection.shared.alerts.enterName")) return } if (!url.trim()) { Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl")) return } if (!parseUrl(url).valid) { Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage")) return } track(AnalyticsEvent.ConnectionFormSubmitted, { mode: "advanced" }) setIsConnecting(true) // Pre-flight, mirroring Quick Connect: previously Advanced mode saved // directly with no health check, so bad credentials (401/403) or an // unreachable server silently became the active connection with zero // feedback (issue #76). testConnection() also fires the // connection_attempted/succeeded/failed analytics events. const result = await testConnection( { id: "", name: name.trim(), type, url: url.trim(), directory: directory.trim() || undefined, username: username.trim() || undefined, }, "onboarding", password || undefined, ) if (result.ok) { try { await addConnection( { name: name.trim(), type, url: url.trim(), directory: directory.trim() || undefined, username: username.trim() || undefined, }, password || undefined, ) setIsConnecting(false) router.back() } catch { setIsConnecting(false) Alert.alert( t("connection.shared.alerts.saveFailedTitle"), t("connection.shared.alerts.saveFailedMessage"), ) } return } // Failed: same "Connection Failed" alert as Quick Connect — run active // diagnostics, capture to Sentry, and offer a shareable report instead of // silently persisting an unreachable/unauthorized connection. const report = await probeConnection(url.trim(), buildAuth(username, password)) captureDiagnostic(report) setIsConnecting(false) Alert.alert( t("connection.shared.alerts.connectionFailedTitle"), t("connection.add.alerts.connectionFailedMessage", { summary: report.summary, target: url.trim(), error: result.error || t("connection.shared.alerts.unknownError"), }), [ { text: t("common.ok"), style: "cancel" }, { text: t("common.shareReport"), onPress: () => shareReport(report) }, ], ) } const handleJoinWaitlist = async () => { if (waitlistState === "submitting") return const attemptedEmail = waitlistEmail setWaitlistState("submitting") const result = await submitWaitlistSignup(attemptedEmail) if (result.ok) { // Clear any earlier queued attempt for the same address so the flush // doesn't re-post it. void dropPendingSignup(result.email) setPendingSignup(null) setWaitlistState("joined") return } if (!result.retryable) { // The server rejected this address; queueing it would retry forever. setWaitlistState(pendingSignup ? "queued" : "idle") Alert.alert(t("connection.add.waitlist.alertTitle"), result.error) return } // Offline / timeout / 5xx: persist and retry later instead of dumping the // user into a mail composer they may never send (AGE-87). const entry = await queuePendingSignup(result.email, result.error) if (entry) { setPendingSignup(entry) setWaitlistState("queued") return } // Storage refused the write — we cannot promise to finish this later, so // offer the manual email path explicitly rather than claiming success. setWaitlistState("idle") Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.queueFailedMessage"), [ { text: t("common.cancel"), style: "cancel" }, { text: t("connection.add.waitlist.emailUsButton"), onPress: () => void openWaitlistMailto(result.email) }, ]) } // Last-resort, user-initiated only. Never opened automatically. const openWaitlistMailto = async (email: string) => { try { await Linking.openURL(buildWaitlistMailtoUrl(email, APP_VERSION)) } catch { Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.noMailAppMessage")) } } // Explicit "Retry" from the queued state — same code path the foreground // flush uses, so there is only one retry implementation. const handleRetryQueued = async () => { setWaitlistState("submitting") const outcome = await flushPendingSignups().catch(() => null) if (outcome && outcome.pending.length === 0 && outcome.synced.length > 0) { setPendingSignup(null) setWaitlistState("joined") return } if (outcome && outcome.pending.length === 0) { // Nothing left pending and nothing synced: the address was rejected. setPendingSignup(null) setWaitlistState("idle") return } if (outcome) setPendingSignup(outcome.pending[outcome.pending.length - 1]) setWaitlistState("queued") } // Quick connect mode - simplified if (mode === "quick") { return ( {t("connection.add.quick.title")} {t("connection.add.quick.subtitle")} {/* IP Address */} {t("connection.add.quick.ipAddressLabel")} : {/* Optional name */} {t("connection.add.quick.nameOptionalLabel")} {/* Password if needed */} {t("connection.add.quick.passwordIfSetLabel")} {t("connection.add.quick.usernameHintPrefix")} opencode {t("connection.add.quick.usernameHintMiddle")} setMode("advanced")}> {t("connection.add.quick.advancedOptionsLink")} {t("connection.add.quick.usernameHintSuffix")} {/* Connect button */} {isConnecting ? ( ) : ( <> {t("connection.add.quick.connectButton")} )} {/* Help text */} {t("connection.add.quick.helpTitle")} {t("connection.add.quick.helpMacPrefix")} {"\n"} ipconfig getifaddr en0 {t("connection.add.quick.helpTailscalePrefix")} {"\n"} http://100.64.12.34:4096 {"\n"} http://my-mac.tailnet.ts.net:4096 {t("connection.add.quick.helpProtocolPrefix")} http:// {t("connection.add.quick.helpProtocolMiddle")} https:// {t("connection.add.quick.helpProtocolSuffix")} {t("connection.add.quick.helpRunningPrefix")} {"\n"} opencode serve --hostname 0.0.0.0 {/* OpenCode Connect — Coming Soon */} {t("connection.add.quick.connectCardTitle")} {t("connection.add.quick.connectCardBadge")} {t("connection.add.quick.connectCardDesc")} {waitlistState === "joined" ? ( {t("connection.add.waitlist.successText")} ) : waitlistState === "queued" ? ( {t("connection.add.waitlist.queuedText")} {needsManualEscapeHatch(pendingSignup) && ( void openWaitlistMailto(pendingSignup?.email ?? waitlistEmail)} testID="waitlist-email-us" > {t("connection.add.waitlist.emailUsLink")} )} void handleRetryQueued()} testID="waitlist-retry"> {t("common.retry")} ) : ( <> {waitlistState === "submitting" ? ( ) : ( <> {t("connection.add.waitlist.joinButton")} )} )} {/* Advanced mode link */} setMode("advanced")}> {t("connection.add.quick.advancedLink")} ) } // Advanced mode - full options return ( setMode("quick")}> {t("connection.add.advanced.backToQuick")} {/* Connection Type */} {t("connection.shared.connectionType")} {[ { type: "local" as const, label: t("connection.shared.types.local"), icon: "wifi" as const }, { type: "tunnel" as const, label: t("connection.shared.types.tunnel"), icon: "globe" as const }, { type: "cloud" as const, label: t("connection.shared.types.cloud"), icon: "cloud" as const }, ].map((opt) => ( setType(opt.type)} > {opt.label} ))} {/* Name */} {t("connection.shared.name")} {/* URL */} {t("connection.shared.serverUrl")} {t("connection.add.advanced.urlHintPrefix")} http://100.64.12.34:4096 {t("connection.add.advanced.urlHintOr")} http://my-mac.tailnet.ts.net:4096 {t("connection.add.advanced.urlHintUse")} https:// {t("connection.add.advanced.urlHintSuffix")} {/* Directory */} {t("connection.shared.directoryOptional")} {t("connection.add.advanced.directoryHint")} {/* Auth */} {t("connection.shared.authentication")} {t("connection.shared.username")} {t("connection.shared.password")} {/* Save */} {isConnecting ? ( ) : ( {t("connection.add.advanced.saveButton")} )} ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#ffffff", }, containerDark: { backgroundColor: "#0a0a0a", }, content: { padding: 16, paddingBottom: 32, }, // Quick connect styles quickHeader: { alignItems: "center", paddingVertical: 24, }, quickTitle: { fontSize: 24, fontWeight: "700", color: "#0a0a0a", marginTop: 16, }, quickSubtitle: { fontSize: 15, color: "#666666", marginTop: 8, textAlign: "center", }, ipRow: { flexDirection: "row", alignItems: "center", gap: 8, }, ipInput: { flex: 1, }, ipColon: { fontSize: 20, fontWeight: "600", color: "#0a0a0a", }, portInput: { width: 80, }, connectButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, padding: 16, borderRadius: 12, backgroundColor: "#0a0a0a", marginTop: 24, }, connectButtonDark: { backgroundColor: "#ffffff", }, connectButtonText: { fontSize: 18, fontWeight: "600", color: "#ffffff", }, connectButtonTextDark: { color: "#0a0a0a", }, helpBox: { backgroundColor: "#f5f5f5", borderRadius: 12, padding: 16, marginTop: 24, }, helpBoxDark: { backgroundColor: "#1a1a1a", }, helpTitle: { fontSize: 14, fontWeight: "600", color: "#0a0a0a", marginBottom: 8, }, helpText: { fontSize: 13, color: "#666666", lineHeight: 20, }, code: { fontFamily: "monospace", backgroundColor: "#e5e5e5", paddingHorizontal: 4, borderRadius: 4, }, usernameHint: { fontSize: 12, color: "#666666", marginTop: 6, marginBottom: 4, lineHeight: 18, }, usernameHintLink: { color: "#6366f1", fontWeight: "600", }, advancedLink: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 4, paddingVertical: 16, marginTop: 16, }, advancedLinkText: { fontSize: 14, color: "#666666", }, backToQuick: { flexDirection: "row", alignItems: "center", gap: 4, paddingVertical: 8, }, backToQuickText: { fontSize: 14, color: "#666666", }, // Original styles label: { fontSize: 14, fontWeight: "600", color: "#0a0a0a", marginTop: 16, marginBottom: 8, }, labelDark: { color: "#ffffff", }, typeContainer: { flexDirection: "row", gap: 8, }, typeOption: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "center", padding: 12, borderRadius: 8, backgroundColor: "#f5f5f5", gap: 6, }, typeOptionDark: { backgroundColor: "#1a1a1a", }, typeOptionSelected: { backgroundColor: "#0a0a0a", }, typeOptionSelectedDark: { backgroundColor: "#ffffff", }, typeLabel: { fontSize: 13, fontWeight: "500", color: "#666666", }, textDark: { color: "#ffffff", }, typeLabelSelected: { color: "#ffffff", }, typeLabelSelectedDark: { color: "#0a0a0a", }, hint: { fontSize: 13, color: "#666666", marginTop: 8, }, hintDark: { color: "#888888", }, input: { backgroundColor: "#f5f5f5", borderRadius: 8, paddingHorizontal: 16, paddingVertical: 12, fontSize: 16, color: "#0a0a0a", }, inputDark: { backgroundColor: "#1a1a1a", color: "#ffffff", }, sectionTitle: { fontSize: 18, fontWeight: "600", color: "#0a0a0a", marginTop: 32, marginBottom: 8, }, connectCard: { backgroundColor: "#f0f0ff", borderRadius: 12, padding: 16, marginTop: 24, borderWidth: 1, borderColor: "#c7d2fe", }, connectCardDark: { backgroundColor: "#1e1b4b", borderColor: "#3730a3", }, connectCardHeader: { flexDirection: "row", alignItems: "center", gap: 12, marginBottom: 8, }, connectCardTitles: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap", }, connectCardTitle: { fontSize: 16, fontWeight: "700", color: "#0a0a0a", }, connectCardBadge: { backgroundColor: "#6366f1", paddingHorizontal: 8, paddingVertical: 2, borderRadius: 4, }, connectCardBadgeText: { color: "#ffffff", fontSize: 11, fontWeight: "600", }, connectCardDesc: { fontSize: 13, color: "#666666", lineHeight: 20, }, waitlistButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, padding: 12, borderRadius: 8, backgroundColor: "#6366f1", marginTop: 12, }, waitlistButtonText: { fontSize: 15, fontWeight: "600", color: "#ffffff", }, waitlistSuccess: { flexDirection: "row", alignItems: "center", gap: 8, marginTop: 12, }, waitlistSuccessText: { flex: 1, fontSize: 13, color: "#0a0a0a", lineHeight: 20, }, waitlistEscapeHatch: { marginTop: 8, paddingVertical: 4, }, waitlistEscapeHatchText: { fontSize: 13, fontWeight: "600", color: "#6366f1", }, })