diff --git a/packages/app/app.config.js b/packages/app/app.config.js index e77784e6f..00bcb9c2a 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -1,11 +1,13 @@ // App name and package ID are controlled by Gradle product flavors (dev/production) // See plugins/with-android-product-flavors.js for flavor configuration +const pkg = require("./package.json"); + export default { expo: { name: "Paseo", slug: "voice-mobile", - version: "0.1.0", + version: pkg.version, orientation: "portrait", icon: "./assets/images/icon.png", scheme: "paseo", diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index d243c050d..8afbf6f96 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -13,7 +13,7 @@ import { router, useLocalSearchParams } from "expo-router"; import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { Sun, Moon, Monitor, MoreVertical } from "lucide-react-native"; +import { Sun, Moon, Monitor, MoreVertical, Globe } from "lucide-react-native"; import { Fonts } from "@/constants/theme"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; @@ -126,16 +126,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, }, - hostUrl: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontFamily: Fonts.mono, - }, - hostConnections: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontFamily: Fonts.mono, - }, hostError: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, @@ -158,6 +148,24 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.semibold, }, + connectionPill: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + maxWidth: 170, + }, + connectionText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, menuButton: { width: 36, height: 32, @@ -390,6 +398,8 @@ export default function SettingsScreen() { const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState(null); const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState(null); const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); + const [pendingRemoveHost, setPendingRemoveHost] = useState(null); + const [isRemovingHost, setIsRemovingHost] = useState(false); const [editingDaemon, setEditingDaemon] = useState(null); const [editLabel, setEditLabel] = useState(""); const [isSavingEdit, setIsSavingEdit] = useState(false); @@ -441,6 +451,19 @@ export default function SettingsScreen() { setEditLabel(""); }, [isSavingEdit]); + const closeAddConnectionFlow = useCallback(() => { + setIsAddHostMethodVisible(false); + setIsDirectHostVisible(false); + setIsPasteLinkVisible(false); + setAddConnectionTargetServerId(null); + }, []); + + const goBackToAddConnectionMethods = useCallback(() => { + setIsDirectHostVisible(false); + setIsPasteLinkVisible(false); + setIsAddHostMethodVisible(true); + }, []); + useEffect(() => { const editHost = typeof params.editHost === "string" ? params.editHost.trim() : ""; if (!editHost) return; @@ -498,45 +521,9 @@ export default function SettingsScreen() { [removeConnection] ); - const handleRemoveDaemon = useCallback( - (profile: HostProfile) => { - if (Platform.OS === "web") { - const hasBrowserConfirm = - typeof globalThis !== "undefined" && - typeof (globalThis as any).confirm === "function"; - - const confirmed = hasBrowserConfirm ? (globalThis as any).confirm(`Remove ${profile.label}?`) : true; - if (!confirmed) return; - - void removeHost(profile.serverId).catch((error) => { - console.error("[Settings] Failed to remove daemon", error); - Alert.alert("Error", "Unable to remove host"); - }); - return; - } - - Alert.alert( - "Remove host", - `Remove ${profile.label}?`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Remove", - style: "destructive", - onPress: async () => { - try { - await removeHost(profile.serverId); - } catch (error) { - console.error("[Settings] Failed to remove daemon", error); - Alert.alert("Error", "Unable to remove host"); - } - }, - }, - ] - ); - }, - [removeHost] - ); + const handleRemoveDaemon = useCallback((profile: HostProfile) => { + setPendingRemoveHost(profile); + }, []); const handleThemeChange = useCallback( (newTheme: AppSettings["theme"]) => { @@ -641,13 +628,20 @@ export default function SettingsScreen() { setIsAddHostMethodVisible(false)} - onDirectConnection={() => setIsDirectHostVisible(true)} - onPasteLink={() => setIsPasteLinkVisible(true)} + onClose={closeAddConnectionFlow} + onDirectConnection={() => { + setIsAddHostMethodVisible(false); + setIsDirectHostVisible(true); + }} + onPasteLink={() => { + setIsAddHostMethodVisible(false); + setIsPasteLinkVisible(true); + }} onScanQr={() => { const target = addConnectionTargetServerId; const source = target ? "editHost" : "settings"; const qs = target ? `?source=${source}&targetServerId=${encodeURIComponent(target)}` : `?source=${source}`; + closeAddConnectionFlow(); router.push(`/pair-scan${qs}`); }} /> @@ -655,7 +649,8 @@ export default function SettingsScreen() { setIsDirectHostVisible(false)} + onClose={closeAddConnectionFlow} + onCancel={goBackToAddConnectionMethods} onSaved={({ serverId, hostname, isNewHost }) => { if (isNewHost) { setPendingNameHost({ serverId, hostname }); @@ -666,7 +661,8 @@ export default function SettingsScreen() { setIsPasteLinkVisible(false)} + onClose={closeAddConnectionFlow} + onCancel={goBackToAddConnectionMethods} onSaved={({ serverId, hostname, isNewHost }) => { if (isNewHost) { setPendingNameHost({ serverId, hostname }); @@ -688,6 +684,51 @@ export default function SettingsScreen() { /> ) : null} + {pendingRemoveHost ? ( + { + if (isRemovingHost) return; + setPendingRemoveHost(null); + }} + testID="remove-host-confirm-modal" + > + + Remove {pendingRemoveHost.label}? This will delete its saved connections. + + + setPendingRemoveHost(null)} + disabled={isRemovingHost} + > + Cancel + + { + const serverId = pendingRemoveHost.serverId; + setIsRemovingHost(true); + void removeHost(serverId) + .then(() => setPendingRemoveHost(null)) + .catch((error) => { + console.error("[Settings] Failed to remove host", error); + Alert.alert("Error", "Unable to remove host"); + }) + .finally(() => setIsRemovingHost(false)); + }} + disabled={isRemovingHost} + testID="remove-host-confirm" + > + + Remove + + + + + ) : null} + { - const parts = daemon.connections.map((conn) => { - if (conn.type === "relay") return `relay:${conn.relayEndpoint}`; - return `direct:${conn.endpoint}`; - }); - return parts.join(" • "); + const connectionBadge = (() => { + if (!activeConnection) return null; + if (activeConnection.type === "relay") { + return { icon: , text: "Relay" }; + } + return { + icon: , + text: activeConnection.display, + }; })(); return ( @@ -1015,6 +1058,15 @@ function DaemonCard({ {badgeText} + {connectionBadge ? ( + + {connectionBadge.icon} + + {connectionBadge.text} + + + ) : null} + - - {(() => { - if (connectionStatus === "online") { - if (activeConnection?.type === "relay") return "Connected via relay"; - if (activeConnection?.type === "direct") return `Connected via ${activeConnection.display}`; - return "Connected"; - } - - if (connectionStatus === "connecting" && activeConnection) { - if (activeConnection.type === "relay") return "Trying relay"; - return `Trying ${activeConnection.display}`; - } - - if ((connectionStatus === "offline" || connectionStatus === "error") && activeConnection) { - if (activeConnection.type === "relay") return "Last tried: relay"; - return `Last tried: ${activeConnection.display}`; - } - - const relay = daemon.connections.find((c) => c.type === "relay"); - const direct = daemon.connections.find((c) => c.type === "direct"); - if (direct) return direct.endpoint; - if (relay) return "Relay"; - return ""; - })()} - - {connectionsSummary ? {connectionsSummary} : null} {connectionError ? {connectionError} : null} diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index c6a51638c..080c58e9c 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -48,19 +48,16 @@ export function AddHostMethodModal({ const { theme } = useUnistyles(); const handleDirect = useCallback(() => { - onClose(); onDirectConnection(); - }, [onClose, onDirectConnection]); + }, [onDirectConnection]); const handleScan = useCallback(() => { - onClose(); onScanQr(); - }, [onClose, onScanQr]); + }, [onScanQr]); const handlePaste = useCallback(() => { - onClose(); onPasteLink(); - }, [onClose, onPasteLink]); + }, [onPasteLink]); return ( diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index d21625837..f81931e66 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -147,10 +147,11 @@ export interface AddHostModalProps { visible: boolean; onClose: () => void; targetServerId?: string; + onCancel?: () => void; onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; } -export function AddHostModal({ visible, onClose, onSaved, targetServerId }: AddHostModalProps) { +export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) { const { theme } = useUnistyles(); const { daemons, upsertDirectConnection } = useDaemonRegistry(); const isMobile = @@ -170,6 +171,13 @@ export function AddHostModal({ visible, onClose, onSaved, targetServerId }: AddH onClose(); }, [isSaving, onClose]); + const handleCancel = useCallback(() => { + if (isSaving) return; + setEndpointRaw(""); + setErrorMessage(""); + (onCancel ?? onClose)(); + }, [isSaving, onCancel, onClose]); + const handleSave = useCallback(async () => { if (isSaving) return; @@ -256,8 +264,8 @@ export function AddHostModal({ visible, onClose, onSaved, targetServerId }: AddH - - Cancel + + {onCancel ? "Back" : "Cancel"} void; targetServerId?: string; + onCancel?: () => void; onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; } -export function PairLinkModal({ visible, onClose, onSaved, targetServerId }: PairLinkModalProps) { +export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) { const { theme } = useUnistyles(); const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry(); const isMobile = @@ -90,6 +91,13 @@ export function PairLinkModal({ visible, onClose, onSaved, targetServerId }: Pai onClose(); }, [isSaving, onClose]); + const handleCancel = useCallback(() => { + if (isSaving) return; + setOfferUrl(""); + setErrorMessage(""); + (onCancel ?? onClose)(); + }, [isSaving, onCancel, onClose]); + const handleSave = useCallback(async () => { if (isSaving) return; const raw = offerUrl.trim(); @@ -178,14 +186,14 @@ export function PairLinkModal({ visible, onClose, onSaved, targetServerId }: Pai - Cancel + {onCancel ? "Back" : "Cancel"}