import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { Pressable, Text, View, Platform, ScrollView } from "react-native"; import { useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; import { getHostRuntimeStore, isHostRuntimeConnected, useHostRuntimeSnapshot, useHosts, } from "@/runtime/host-runtime"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; import { resolveAppVersion } from "@/utils/app-version"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { buildHostRootRoute } from "@/utils/host-routes"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { openExternalUrl } from "@/utils/open-external-url"; type WelcomeAction = { key: "scan-qr" | "direct-connection" | "paste-pairing-link"; label: string; testID: string; primary: boolean; icon: typeof QrCode; onPress: () => void; }; const styles = StyleSheet.create((theme) => ({ container: { flexGrow: 1, backgroundColor: theme.colors.surface0, padding: theme.spacing[6], paddingBottom: 0, alignItems: "center", }, content: { width: "100%", flexGrow: 1, justifyContent: "center", alignItems: "center", }, title: { color: theme.colors.foreground, fontSize: theme.fontSize.xl, fontWeight: theme.fontWeight.medium, 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.accent, borderColor: theme.colors.accent, }, actionText: { color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, actionTextPrimary: { color: theme.colors.accentForeground, }, hostList: { width: "100%", maxWidth: 420, marginTop: theme.spacing[6], borderTopWidth: 1, borderTopColor: theme.colors.border, paddingTop: theme.spacing[4], gap: theme.spacing[2], }, hostRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], paddingVertical: theme.spacing[2], }, statusDot: { width: 8, height: 8, borderRadius: 4, }, hostLabel: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, hostStatus: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, hostStatusError: { color: theme.colors.destructive, fontSize: theme.fontSize.sm, }, setupHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", marginBottom: theme.spacing[6], lineHeight: theme.fontSize.sm * 1.5, }, setupLink: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6, marginBottom: theme.spacing[6], }, setupLinkText: { color: theme.colors.accent, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, versionLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, textAlign: "center", marginTop: theme.spacing[6], }, })); function useAnyHostOnline(serverIds: string[]): string | null { const runtime = getHostRuntimeStore(); return useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => { let firstOnlineServerId: string | null = null; let firstOnlineAt: string | null = null; for (const serverId of serverIds) { const snapshot = runtime.getSnapshot(serverId); const lastOnlineAt = snapshot?.lastOnlineAt ?? null; if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { continue; } if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { firstOnlineAt = lastOnlineAt; firstOnlineServerId = serverId; } } return firstOnlineServerId; }, () => { let firstOnlineServerId: string | null = null; let firstOnlineAt: string | null = null; for (const serverId of serverIds) { const snapshot = runtime.getSnapshot(serverId); const lastOnlineAt = snapshot?.lastOnlineAt ?? null; if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { continue; } if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { firstOnlineAt = lastOnlineAt; firstOnlineServerId = serverId; } } return firstOnlineServerId; }, ); } function HostStatusRow({ serverId, label }: { serverId: string; label: string }) { const { theme } = useUnistyles(); const snapshot = useHostRuntimeSnapshot(serverId); const status = snapshot?.connectionStatus ?? "connecting"; const lastError = snapshot?.lastError ?? null; let dotColor: string; let statusText: string; let isError = false; switch (status) { case "online": dotColor = theme.colors.success; statusText = "Online"; break; case "connecting": case "idle": dotColor = theme.colors.foregroundMuted; statusText = "Connecting…"; break; case "offline": dotColor = theme.colors.foregroundMuted; statusText = "Offline"; break; case "error": dotColor = theme.colors.destructive; statusText = lastError ? lastError.slice(0, 40) : "Connection error"; isError = true; break; } return ( {label} {statusText} ); } export interface WelcomeScreenProps { onHostAdded?: (profile: HostProfile) => void; } export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); const [isDirectOpen, setIsDirectOpen] = useState(false); const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false); const hosts = useHosts(); const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); useEffect(() => { if (!anyOnlineServerId) { return; } router.replace(buildHostRootRoute(anyOnlineServerId)); }, [anyOnlineServerId, router]); const finishOnboarding = useCallback( (serverId: string) => { router.replace(buildHostRootRoute(serverId)); }, [router], ); const actions: WelcomeAction[] = Platform.OS === "web" ? [ { key: "direct-connection", label: "Direct connection", testID: "welcome-direct-connection", primary: true, icon: Link2, onPress: () => setIsDirectOpen(true), }, { key: "paste-pairing-link", label: "Paste pairing link", testID: "welcome-paste-pairing-link", primary: false, icon: ClipboardPaste, onPress: () => setIsPasteLinkOpen(true), }, ] : [ { key: "scan-qr", label: "Scan QR code", testID: "welcome-scan-qr", primary: true, icon: QrCode, onPress: () => router.push("/pair-scan?source=onboarding"), }, { key: "direct-connection", label: "Direct connection", testID: "welcome-direct-connection", primary: false, icon: Link2, onPress: () => setIsDirectOpen(true), }, { key: "paste-pairing-link", label: "Paste pairing link", testID: "welcome-paste-pairing-link", primary: false, icon: ClipboardPaste, onPress: () => setIsPasteLinkOpen(true), }, ]; const showHostList = hosts.length > 0 && !anyOnlineServerId; return ( Welcome to Paseo {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} {!showHostList && Platform.OS !== "web" && ( <> You need the Paseo desktop app or server running on your computer first. openExternalUrl("https://paseo.sh")}> Get started at paseo.sh )} {actions.map((action) => { const Icon = action.icon; return ( {action.label} ); })} {showHostList && ( {hosts.map((host) => ( ))} )} {appVersionText} setIsDirectOpen(false)} onSaved={({ profile, serverId }) => { onHostAdded?.(profile); finishOnboarding(serverId); }} /> setIsPasteLinkOpen(false)} onSaved={({ profile, serverId }) => { onHostAdded?.(profile); finishOnboarding(serverId); }} /> ); }