Files
paseo/packages/app/src/components/pair-link-modal.tsx
T
Mohamed BoudraandGitHub a3951495d4 refactor(app): improve terminal profile modal form and zinc button contrast
- 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
2026-06-12 19:30:14 +08:00

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>
);
}