Add welcome onboarding and remove default localhost host
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { X } from "lucide-react-native";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
desktopOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
desktopCard: {
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
maxHeight: "85%",
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surface2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingVertical: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
closeButton: {
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
desktopScroll: {
|
||||
flex: 1,
|
||||
},
|
||||
desktopContent: {
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
bottomSheetContent: {
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdaptiveModalSheetProps {
|
||||
title: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
snapPoints?: string[];
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
title,
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
snapPoints,
|
||||
testID,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const sheetRef = useRef<BottomSheetModal>(null);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (visible) {
|
||||
sheetRef.current?.present();
|
||||
} else {
|
||||
sheetRef.current?.dismiss();
|
||||
}
|
||||
}, [visible, isMobile]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={resolvedSnapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityLabel="Close"
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
hardwareAccelerated
|
||||
>
|
||||
<View style={styles.desktopOverlay} testID={testID}>
|
||||
<Pressable
|
||||
accessibilityLabel="Dismiss"
|
||||
style={{ ...StyleSheet.absoluteFillObject }}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={styles.desktopCard}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityLabel="Close"
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback } from "react";
|
||||
import { Pressable, Text, View, Platform } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
option: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[4],
|
||||
padding: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
optionText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
optionSubtext: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
optionBody: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
export interface AddHostMethodModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDirectConnection: () => void;
|
||||
onScanQr: () => void;
|
||||
onPasteLink: () => void;
|
||||
}
|
||||
|
||||
export function AddHostMethodModal({
|
||||
visible,
|
||||
onClose,
|
||||
onDirectConnection,
|
||||
onScanQr,
|
||||
onPasteLink,
|
||||
}: AddHostMethodModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const handleDirect = useCallback(() => {
|
||||
onClose();
|
||||
onDirectConnection();
|
||||
}, [onClose, onDirectConnection]);
|
||||
|
||||
const handleScan = useCallback(() => {
|
||||
onClose();
|
||||
onScanQr();
|
||||
}, [onClose, onScanQr]);
|
||||
|
||||
const handlePaste = useCallback(() => {
|
||||
onClose();
|
||||
onPasteLink();
|
||||
}, [onClose, onPasteLink]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Add host" visible={visible} onClose={onClose} testID="add-host-method-modal">
|
||||
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Direct connection</Text>
|
||||
<Text style={styles.optionSubtext}>Enter a host and port.</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{Platform.OS !== "web" ? (
|
||||
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Scan QR code</Text>
|
||||
<Text style={styles.optionSubtext}>Use your camera to pair.</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link">
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||
<Text style={styles.optionSubtext}>Works without a camera.</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Pressable, Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { Link2 } from "lucide-react-native";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError, testDaemonEndpointConnection } from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
|
||||
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,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
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,
|
||||
},
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
connectRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
function isHostPortOnly(raw: string): boolean {
|
||||
return !raw.includes("://") && !raw.includes("/");
|
||||
}
|
||||
|
||||
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: Array<string | null>): 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 `${unique[0]} (no additional details provided)`;
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } {
|
||||
const title = `We failed to connect to ${endpoint}.`;
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError]) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return normalizeTransportMessage(error.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (rawLower.includes("timed out")) {
|
||||
detail = "Connection timed out. Check the host/port and your network.";
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = "Connection was refused. Is the daemon running on that host and port?";
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = "Host not found. Check the hostname and try again.";
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = "Host is unreachable. Check your network and firewall.";
|
||||
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
|
||||
detail = "TLS/certificate error. This app expects a daemon reachable over the local network or via relay.";
|
||||
} else if (raw) {
|
||||
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
||||
} else {
|
||||
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
}
|
||||
|
||||
export interface AddHostModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: (profile: HostProfile) => void;
|
||||
}
|
||||
|
||||
export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { addDaemon } = 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();
|
||||
}, [isSaving, onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
|
||||
const raw = endpointRaw.trim();
|
||||
if (!raw) {
|
||||
setErrorMessage("Host is required");
|
||||
return;
|
||||
}
|
||||
if (!isHostPortOnly(raw)) {
|
||||
setErrorMessage("Enter host:port only (no ws://, no /ws)");
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint: string;
|
||||
try {
|
||||
endpoint = normalizeHostPort(raw);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid host:port";
|
||||
setErrorMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
await testDaemonEndpointConnection(endpoint);
|
||||
|
||||
const profile = await addDaemon({
|
||||
label: label.trim(),
|
||||
endpoints: [endpoint],
|
||||
});
|
||||
|
||||
onSaved?.(profile);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
|
||||
const combined =
|
||||
raw && detail && raw !== detail
|
||||
? `${title}\n${detail}\nDetails: ${raw}`
|
||||
: detail
|
||||
? `${title}\n${detail}`
|
||||
: title;
|
||||
setErrorMessage(combined);
|
||||
if (!isMobile) {
|
||||
// Desktop/web: also surface it as a dialog for quick visibility.
|
||||
Alert.alert("Connection failed", combined);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [addDaemon, endpointRaw, handleClose, isMobile, isSaving, label, onSaved]);
|
||||
|
||||
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
|
||||
ref={hostInputRef as any}
|
||||
value={endpointRaw}
|
||||
onChangeText={setEndpointRaw}
|
||||
placeholder="host:6767"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => void handleSave()}
|
||||
/>
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}>
|
||||
<Text style={styles.buttonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
|
||||
onPress={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<View style={styles.connectRow}>
|
||||
<Link2 size={16} color={theme.colors.palette.white} />
|
||||
<Text style={[styles.buttonText, styles.primaryButtonText]}>
|
||||
{isSaving ? "Connecting..." : "Connect & Save"}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
@@ -58,12 +58,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
|
||||
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? buildDaemonWebSocketUrl("localhost:6767");
|
||||
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? null;
|
||||
|
||||
const lastAttemptedUrlRef = useRef<string | null>(null);
|
||||
const pendingMetadataWriteRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeUrl) {
|
||||
return;
|
||||
}
|
||||
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
|
||||
const idx = candidates.indexOf(activeUrl);
|
||||
if (idx === -1) {
|
||||
@@ -72,6 +75,10 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [candidates.join("|")]);
|
||||
|
||||
if (!activeUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connection = connectionStates.get(daemon.id);
|
||||
const status = connection?.status ?? "idle";
|
||||
const lastError = connection?.lastError ?? null;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Alert, Pressable, Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { Link } from "lucide-react-native";
|
||||
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
|
||||
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],
|
||||
},
|
||||
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,
|
||||
},
|
||||
connectRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
export interface PairLinkModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: (profile: HostProfile) => void;
|
||||
}
|
||||
|
||||
export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
|
||||
|
||||
const [offerUrl, setOfferUrl] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setOfferUrl("");
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
}, [isSaving, onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
const raw = offerUrl.trim();
|
||||
if (!raw) {
|
||||
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
||||
return;
|
||||
}
|
||||
if (!raw.includes("#offer=")) {
|
||||
setErrorMessage("Link must include #offer=...");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const profile = await upsertDaemonFromOfferUrl(raw);
|
||||
onSaved?.(profile);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Pairing failed", message);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [handleClose, isMobile, isSaving, offerUrl, onSaved, upsertDaemonFromOfferUrl]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
|
||||
<Text style={styles.helper}>Paste the daemon’s pairing link.</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Pairing link</Text>
|
||||
<InputComponent
|
||||
value={offerUrl}
|
||||
onChangeText={setOfferUrl}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}>
|
||||
<Text style={styles.buttonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
|
||||
onPress={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<View style={styles.connectRow}>
|
||||
<Link size={16} color={theme.colors.palette.white} />
|
||||
<Text style={[styles.buttonText, styles.primaryButtonText]}>
|
||||
{isSaving ? "Pairing..." : "Pair"}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { Image, Pressable, Text, View, Platform } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2 } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[8],
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
logo: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xl,
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
marginBottom: theme.spacing[3],
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
marginBottom: theme.spacing[8],
|
||||
},
|
||||
actions: {
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
actionButtonPrimary: {
|
||||
backgroundColor: theme.colors.palette.blue[500],
|
||||
borderColor: theme.colors.palette.blue[500],
|
||||
},
|
||||
actionText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
actionTextPrimary: {
|
||||
color: theme.colors.palette.white,
|
||||
},
|
||||
}));
|
||||
|
||||
export interface WelcomeScreenProps {
|
||||
onHostAdded?: (profile: HostProfile) => void;
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="welcome-screen">
|
||||
<Image
|
||||
source={require("../../assets/images/icon.png")}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Add a host to start.</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.actionButtonPrimary]}
|
||||
onPress={() => setIsDirectOpen(true)}
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.palette.white} />
|
||||
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
|
||||
</Pressable>
|
||||
|
||||
{Platform.OS !== "web" ? (
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={() => router.push("/pair-scan")}
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<Text style={styles.actionText}>Scan QR code</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<AddHostModal
|
||||
visible={isDirectOpen}
|
||||
onClose={() => setIsDirectOpen(false)}
|
||||
onSaved={(profile) => {
|
||||
onHostAdded?.(profile);
|
||||
router.replace({ pathname: "/", params: { serverId: profile.id } });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user