- 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
231 lines
7.1 KiB
TypeScript
231 lines
7.1 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Alert, Text, TextInput, View } from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { Link } from "lucide-react-native";
|
|
import type { HostProfile } from "@/types/host-connection";
|
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
|
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
|
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
helper: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
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,
|
|
},
|
|
error: {
|
|
color: theme.colors.destructive,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
actions: {
|
|
flexDirection: "row",
|
|
gap: theme.spacing[3],
|
|
marginTop: theme.spacing[2],
|
|
},
|
|
}));
|
|
|
|
export interface PairLinkModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
onCancel?: () => void;
|
|
onSaved?: (result: {
|
|
profile: HostProfile;
|
|
serverId: string;
|
|
hostname: string | null;
|
|
isNewHost: boolean;
|
|
}) => void;
|
|
}
|
|
|
|
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const daemons = useHosts();
|
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
|
const isMobile = useIsCompactFormFactor();
|
|
|
|
const offerUrlRef = useRef("");
|
|
const inputRef = useRef<TextInput>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState("");
|
|
|
|
const clearInput = useCallback(() => {
|
|
offerUrlRef.current = "";
|
|
inputRef.current?.clear();
|
|
}, []);
|
|
|
|
const pairIcon = useMemo(
|
|
() => <Link size={16} color={theme.colors.accentForeground} />,
|
|
[theme.colors.accentForeground],
|
|
);
|
|
|
|
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;
|
|
const raw = offerUrlRef.current.trim();
|
|
if (!raw) {
|
|
setErrorMessage(t("pairing.link.errors.required"));
|
|
return;
|
|
}
|
|
if (!raw.includes("#offer=")) {
|
|
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
|
return;
|
|
}
|
|
|
|
const parsedOffer = (() => {
|
|
try {
|
|
const idx = raw.indexOf("#offer=");
|
|
const encoded = raw.slice(idx + "#offer=".length).trim();
|
|
if (!encoded) {
|
|
throw new Error(t("pairing.link.errors.emptyOffer"));
|
|
}
|
|
const payload = decodeOfferFragmentPayload(encoded);
|
|
return ConnectionOfferSchema.parse(payload);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
|
|
setErrorMessage(message);
|
|
if (!isMobile) {
|
|
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
|
}
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
if (!parsedOffer) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
setErrorMessage("");
|
|
|
|
const { client, hostname } = await connectToDaemon(
|
|
{
|
|
id: "probe",
|
|
type: "relay",
|
|
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
|
|
useTls: parsedOffer.relay.useTls,
|
|
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
|
|
},
|
|
{ serverId: parsedOffer.serverId },
|
|
);
|
|
await client.close().catch(() => undefined);
|
|
|
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
|
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
|
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
|
handleClose();
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
|
setErrorMessage(message);
|
|
if (!isMobile) {
|
|
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
|
}
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
|
|
|
|
const handleChangeOfferUrl = useCallback((next: string) => {
|
|
offerUrlRef.current = next;
|
|
}, []);
|
|
|
|
const handleSavePress = useCallback(() => {
|
|
void handleSave();
|
|
}, [handleSave]);
|
|
|
|
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.link.title") }), [t]);
|
|
|
|
return (
|
|
<AdaptiveModalSheet
|
|
header={header}
|
|
visible={visible}
|
|
onClose={handleClose}
|
|
testID="pair-link-modal"
|
|
>
|
|
<Text style={styles.helper}>{t("pairing.link.helper")}</Text>
|
|
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
|
<AdaptiveTextInput
|
|
ref={inputRef}
|
|
testID="pair-link-input"
|
|
nativeID="pair-link-input"
|
|
accessibilityLabel={t("pairing.link.label")}
|
|
onChangeText={handleChangeOfferUrl}
|
|
placeholder="https://app.paseo.sh/#offer=..."
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
style={styles.input}
|
|
autoFocus
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
keyboardType="url"
|
|
/>
|
|
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
|
</View>
|
|
|
|
<View style={styles.actions}>
|
|
<Button
|
|
style={FLEX_ONE_STYLE}
|
|
variant="secondary"
|
|
onPress={handleCancel}
|
|
disabled={isSaving}
|
|
testID="pair-link-cancel"
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("pairing.link.actions.cancel")}
|
|
>
|
|
{t("pairing.link.actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
style={FLEX_ONE_STYLE}
|
|
variant="default"
|
|
onPress={handleSavePress}
|
|
disabled={isSaving}
|
|
testID="pair-link-submit"
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("pairing.link.actions.pair")}
|
|
leftIcon={pairIcon}
|
|
>
|
|
{isSaving ? t("pairing.link.actions.pairing") : t("pairing.link.actions.pair")}
|
|
</Button>
|
|
</View>
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|