import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; import { Button } from "@/components/ui/button"; 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"; import { isWeb, isNative } from "@/constants/platform"; interface 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) => ({ root: { flex: 1, backgroundColor: theme.colors.surface0, }, scrollView: { flex: 1, }, container: { flexGrow: 1, 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, textAlign: "center", }, subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, copyBlock: { alignItems: "center", gap: theme.spacing[2], marginBottom: theme.spacing[12], }, 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, }, setupLink: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 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], }, settingsButton: { alignSelf: "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; }, ); } 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 handleOpenPaseoSite = useCallback(() => { void openExternalUrl("https://paseo.sh"); }, []); const handleOpenSettings = useCallback(() => { router.push("/settings"); }, [router]); const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []); const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []); const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []); const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []); const handleScanQr = useCallback(() => { router.push("/pair-scan?source=onboarding"); }, [router]); const handleHostSaved = useCallback( ({ profile, serverId }: { profile: HostProfile; serverId: string }) => { onHostAdded?.(profile); finishOnboarding(serverId); }, [onHostAdded, finishOnboarding], ); const actions: WelcomeAction[] = isWeb ? [ { key: "direct-connection", label: "Direct connection", testID: "welcome-direct-connection", primary: true, icon: Link2, onPress: handleOpenDirect, }, { key: "paste-pairing-link", label: "Paste pairing link", testID: "welcome-paste-pairing-link", primary: false, icon: ClipboardPaste, onPress: handleOpenPasteLink, }, ] : [ { key: "scan-qr", label: "Scan QR code", testID: "welcome-scan-qr", primary: true, icon: QrCode, onPress: handleScanQr, }, { key: "direct-connection", label: "Direct connection", testID: "welcome-direct-connection", primary: false, icon: Link2, onPress: handleOpenDirect, }, { key: "paste-pairing-link", label: "Paste pairing link", testID: "welcome-paste-pairing-link", primary: false, icon: ClipboardPaste, onPress: handleOpenPasteLink, }, ]; const scrollContentContainerStyle = useMemo( () => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }], [theme.spacing, insets.bottom], ); return ( Welcome to Paseo Connect your computer to get started {isNative ? ( paseo.sh ) : null} {actions.map((action) => ( ))} {appVersionText} ); } interface WelcomeActionButtonProps { action: WelcomeAction; } function WelcomeActionButton({ action }: WelcomeActionButtonProps) { const { theme } = useUnistyles(); const Icon = action.icon; const buttonStyle = useMemo( () => [styles.actionButton, action.primary ? styles.actionButtonPrimary : null], [action.primary], ); const textStyle = useMemo( () => [styles.actionText, action.primary ? styles.actionTextPrimary : null], [action.primary], ); return ( {action.label} ); }