Update files
This commit is contained in:
@@ -146,27 +146,25 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
|
||||
export interface AddHostModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: (profile: HostProfile) => void;
|
||||
targetServerId?: string;
|
||||
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
|
||||
}
|
||||
|
||||
export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
export function AddHostModal({ visible, onClose, onSaved, targetServerId }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { upsertDirectConnection } = useDaemonRegistry();
|
||||
const { daemons, upsertDirectConnection } = useDaemonRegistry();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
||||
const labelInputRef = useRef<TextInput>(null);
|
||||
const hostInputRef = useRef<TextInput>(null);
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const [endpointRaw, setEndpointRaw] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setLabel("");
|
||||
setEndpointRaw("");
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
@@ -198,14 +196,23 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
const { serverId } = await probeDaemonEndpoint(endpoint);
|
||||
const { serverId, hostname } = await probeDaemonEndpoint(endpoint);
|
||||
if (targetServerId && serverId !== targetServerId) {
|
||||
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Wrong daemon", message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
const profile = await upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint,
|
||||
label: label.trim(),
|
||||
});
|
||||
|
||||
onSaved?.(profile);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
|
||||
@@ -223,30 +230,12 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [endpointRaw, handleClose, isMobile, isSaving, label, onSaved, upsertDirectConnection]);
|
||||
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
|
||||
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Label (optional)</Text>
|
||||
<InputComponent
|
||||
ref={labelInputRef as any}
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
placeholder="My Host"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSaving}
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => hostInputRef.current?.focus()}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Host</Text>
|
||||
<InputComponent
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
field: {
|
||||
marginTop: theme.spacing[3],
|
||||
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,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: theme.colors.palette.blue[500],
|
||||
},
|
||||
buttonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: theme.colors.palette.white,
|
||||
},
|
||||
}));
|
||||
|
||||
export interface NameHostModalProps {
|
||||
visible: boolean;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
onSkip: () => void;
|
||||
onSave: (label: string) => void;
|
||||
}
|
||||
|
||||
export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const hasEditedRef = useRef(false);
|
||||
|
||||
const suggested = (hostname?.trim() || serverId).trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setLabel(suggested);
|
||||
hasEditedRef.current = false;
|
||||
}, [suggested, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (hasEditedRef.current) return;
|
||||
if (!hostname) return;
|
||||
const trimmed = label.trim();
|
||||
if (trimmed.length === 0 || trimmed === serverId) {
|
||||
setLabel(hostname.trim());
|
||||
}
|
||||
}, [hostname, label, serverId, visible]);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
hasEditedRef.current = true;
|
||||
setLabel(value);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) {
|
||||
onSkip();
|
||||
return;
|
||||
}
|
||||
onSave(trimmed);
|
||||
}, [label, onSave, onSkip]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Name this host" visible={visible} onClose={onSkip} testID="name-host-modal">
|
||||
<Text style={styles.helper}>
|
||||
Optional. You can rename this later in Settings.
|
||||
</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Label</Text>
|
||||
<InputComponent
|
||||
value={label}
|
||||
onChangeText={handleChange}
|
||||
placeholder={suggested}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.button} onPress={onSkip} testID="name-host-skip">
|
||||
<Text style={styles.buttonText}>Skip</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.button, styles.primaryButton]} onPress={handleSave} testID="name-host-save">
|
||||
<Text style={[styles.buttonText, styles.primaryButtonText]}>Save</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { Link } from "lucide-react-native";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { decodeOfferFragmentPayload } from "@/utils/daemon-endpoints";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -66,12 +68,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
export interface PairLinkModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: (profile: HostProfile) => void;
|
||||
targetServerId?: string;
|
||||
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
|
||||
}
|
||||
|
||||
export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) {
|
||||
export function PairLinkModal({ visible, onClose, onSaved, targetServerId }: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||
const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
||||
@@ -99,11 +102,44 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedOffer = (() => {
|
||||
try {
|
||||
const idx = raw.indexOf("#offer=");
|
||||
const encoded = raw.slice(idx + "#offer=".length).trim();
|
||||
if (!encoded) {
|
||||
throw new Error("Offer payload is empty");
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
return ConnectionOfferSchema.parse(payload);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid pairing link";
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Pairing failed", message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!parsedOffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetServerId && parsedOffer.serverId !== targetServerId) {
|
||||
const message = `That pairing link belongs to ${parsedOffer.serverId}, not ${targetServerId}.`;
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Wrong daemon", message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(raw);
|
||||
onSaved?.(profile);
|
||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: null, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
@@ -114,7 +150,7 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [handleClose, isMobile, isSaving, offerUrl, onSaved, upsertDaemonFromOfferUrl]);
|
||||
}, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
import { NameHostModal } from "./name-host-modal";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -70,8 +73,27 @@ export interface WelcomeScreenProps {
|
||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const { updateHost } = useDaemonRegistry();
|
||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
|
||||
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!pendingNameHost) return null;
|
||||
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
|
||||
},
|
||||
[pendingNameHost]
|
||||
)
|
||||
);
|
||||
|
||||
const finishOnboarding = useCallback(
|
||||
(serverId: string) => {
|
||||
router.replace({ pathname: "/", params: { serverId } });
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -110,7 +132,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
{Platform.OS !== "web" ? (
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => router.push("/pair-scan")}
|
||||
onPress={() => router.push("/pair-scan?source=onboarding")}
|
||||
testID="welcome-scan-qr"
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
@@ -122,20 +144,52 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<AddHostModal
|
||||
visible={isDirectOpen}
|
||||
onClose={() => setIsDirectOpen(false)}
|
||||
onSaved={(profile) => {
|
||||
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||
onHostAdded?.(profile);
|
||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
||||
setPendingRedirectServerId(serverId);
|
||||
if (isNewHost) {
|
||||
setPendingNameHost({ serverId, hostname });
|
||||
return;
|
||||
}
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={() => setIsPasteLinkOpen(false)}
|
||||
onSaved={(profile) => {
|
||||
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||
onHostAdded?.(profile);
|
||||
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
|
||||
setPendingRedirectServerId(serverId);
|
||||
if (isNewHost) {
|
||||
setPendingNameHost({ serverId, hostname });
|
||||
return;
|
||||
}
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pendingNameHost && pendingRedirectServerId ? (
|
||||
<NameHostModal
|
||||
visible
|
||||
serverId={pendingNameHost.serverId}
|
||||
hostname={pendingNameHostname}
|
||||
onSkip={() => {
|
||||
const serverId = pendingRedirectServerId;
|
||||
setPendingNameHost(null);
|
||||
setPendingRedirectServerId(null);
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
onSave={(label) => {
|
||||
const serverId = pendingRedirectServerId;
|
||||
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
|
||||
setPendingNameHost(null);
|
||||
setPendingRedirectServerId(null);
|
||||
finishOnboarding(serverId);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user