- Extract terminal profile add/edit modal into a reusable component with shared FormField/FormTextInput primitives. - Add inline validation, save-pending state, and keyboard navigation. - Fix zinc theme primary button foreground contrast by using accentForeground/destructiveForeground tokens. Closes #1484
657 lines
20 KiB
TypeScript
657 lines
20 KiB
TypeScript
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(
|
|
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
|
[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<DirectConnectionLabels>(
|
|
() => ({
|
|
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<SheetHeader>(() => ({ 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 (
|
|
<AdaptiveModalSheet
|
|
header={header}
|
|
visible={visible}
|
|
onClose={handleClose}
|
|
testID="add-host-modal"
|
|
>
|
|
<Text style={styles.helper}>{t("pairing.direct.helper")}</Text>
|
|
|
|
<View style={styles.portRow}>
|
|
<View style={hostFieldStyle}>
|
|
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
|
|
<AdaptiveTextInput
|
|
testID="direct-host-input"
|
|
nativeID="direct-host-input"
|
|
accessibilityLabel={t("pairing.direct.fields.host")}
|
|
initialValue={host}
|
|
resetKey={`direct-host-${inputResetKey}`}
|
|
value={host}
|
|
onChangeText={setHost}
|
|
placeholder="localhost"
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
style={styles.input}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
keyboardType="url"
|
|
editable={!isSaving}
|
|
returnKeyType="next"
|
|
/>
|
|
</View>
|
|
<View style={portFieldStyle}>
|
|
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
|
|
<AdaptiveTextInput
|
|
testID="direct-port-input"
|
|
nativeID="direct-port-input"
|
|
accessibilityLabel={t("pairing.direct.fields.port")}
|
|
initialValue={port}
|
|
resetKey={`direct-port-${inputResetKey}`}
|
|
value={port}
|
|
onChangeText={setPort}
|
|
placeholder="6767"
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
style={styles.input}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
keyboardType="number-pad"
|
|
editable={!isSaving}
|
|
returnKeyType="done"
|
|
onSubmitEditing={handleSubmitEditing}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
<Pressable
|
|
style={styles.checkboxRow}
|
|
onPress={handleToggleUseTls}
|
|
disabled={isSaving}
|
|
accessibilityRole="checkbox"
|
|
accessibilityLabel={t("pairing.direct.fields.useSsl")}
|
|
accessibilityState={useTlsAccessibilityState}
|
|
testID="direct-ssl-toggle"
|
|
>
|
|
<View style={checkboxStyle}>
|
|
{useTls ? (
|
|
<View testID="direct-ssl-toggle-checked">
|
|
<Check size={14} color={theme.colors.accentForeground} />
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
|
|
</Pressable>
|
|
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
|
|
<View style={styles.passwordRow}>
|
|
<AdaptiveTextInput
|
|
testID="direct-password-input"
|
|
nativeID="direct-password-input"
|
|
accessibilityLabel={t("pairing.direct.fields.password")}
|
|
initialValue={password}
|
|
resetKey={`direct-password-${inputResetKey}`}
|
|
value={password}
|
|
onChangeText={setPassword}
|
|
placeholder={t("pairing.direct.fields.optional")}
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
style={passwordInputStyle}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
secureTextEntry={!isPasswordVisible}
|
|
editable={!isSaving}
|
|
returnKeyType="done"
|
|
onSubmitEditing={handleSubmitEditing}
|
|
/>
|
|
<Pressable
|
|
style={styles.iconButton}
|
|
onPress={handleTogglePasswordVisibility}
|
|
disabled={isSaving}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
isPasswordVisible
|
|
? t("pairing.direct.passwordVisibility.hide")
|
|
: t("pairing.direct.passwordVisibility.show")
|
|
}
|
|
testID="direct-password-visibility-toggle"
|
|
>
|
|
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.field}>
|
|
<Pressable
|
|
style={styles.advancedToggle}
|
|
onPress={handleToggleAdvanced}
|
|
disabled={isSaving}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
isAdvancedOpen ? t("pairing.direct.advanced.hide") : t("pairing.direct.advanced.show")
|
|
}
|
|
testID="direct-host-advanced-toggle"
|
|
>
|
|
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
|
|
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
|
</Pressable>
|
|
{isAdvancedOpen ? (
|
|
<AdaptiveTextInput
|
|
testID="direct-host-uri-input"
|
|
nativeID="direct-host-uri-input"
|
|
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
|
initialValue={advancedUri}
|
|
resetKey={`direct-host-uri-${inputResetKey}`}
|
|
value={advancedUri}
|
|
onChangeText={setAdvancedUri}
|
|
placeholder="tcp://localhost:6767?ssl=true"
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
style={styles.input}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
keyboardType="url"
|
|
editable={!isSaving}
|
|
returnKeyType="done"
|
|
onSubmitEditing={handleToggleAdvanced}
|
|
/>
|
|
) : null}
|
|
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
|
</View>
|
|
|
|
<View style={styles.actions}>
|
|
<Button
|
|
style={FLEX_ONE_STYLE}
|
|
variant="secondary"
|
|
onPress={handleCancel}
|
|
disabled={isSaving}
|
|
>
|
|
{t("pairing.direct.actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
style={FLEX_ONE_STYLE}
|
|
variant="default"
|
|
onPress={handleSavePress}
|
|
disabled={isSaving}
|
|
leftIcon={connectIcon}
|
|
testID="direct-host-submit"
|
|
>
|
|
{isSaving ? t("pairing.direct.actions.connecting") : t("pairing.direct.actions.connect")}
|
|
</Button>
|
|
</View>
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|