import { ChevronDownIcon } from 'lucide-react-native'; import React, { useCallback } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; import { getSystemConfig } from '@/api/config'; import { useKeyboardHeight } from '@/hooks/use-keyboard-height'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import type { ResgridSystemLocation } from '@/models/v4/configs/getSystemConfigResultData'; import { useServerUrlStore } from '@/stores/app/server-url-store'; import useAuthStore from '@/stores/auth/store'; import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet'; import { Button, ButtonSpinner, ButtonText } from '../ui/button'; import { Center } from '../ui/center'; import { FormControl, FormControlError, FormControlErrorText, FormControlHelperText, FormControlLabel, FormControlLabelText } from '../ui/form-control'; import { HStack } from '../ui/hstack'; import { Input, InputField } from '../ui/input'; import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragIndicatorWrapper, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '../ui/select'; import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; interface ServerUrlForm { url: string; } interface ServerUrlBottomSheetProps { isOpen: boolean; onClose: () => void; /** Called after a new URL is saved while signed in; tokens belong to the old server, so the caller should sign out. */ onUrlChanged?: () => Promise; } const URL_PATTERN = /^https?:\/\/.+/; const CUSTOM_SERVER_VALUE = '__custom__'; const API_PATH_SUFFIX = `/api/${Env.API_VERSION}`; const normalizeInputUrl = (url: string) => url.trim().replace(/\/+$/, ''); // Strips the `/api/{version}` suffix so stored API URLs and system config ApiUrls (which may or // may not include it) compare equal. Any other path is kept for self-hosted installs under a sub-path. const normalizeBaseUrl = (url: string) => { const trimmedUrl = normalizeInputUrl(url); if (trimmedUrl.endsWith(API_PATH_SUFFIX)) { return trimmedUrl.slice(0, -API_PATH_SUFFIX.length).replace(/\/+$/, ''); } return trimmedUrl; }; const buildApiUrl = (url: string) => `${normalizeBaseUrl(url)}${API_PATH_SUFFIX}`; export function ServerUrlBottomSheet({ isOpen, onClose, onUrlChanged }: ServerUrlBottomSheetProps) { const { t } = useTranslation(); const keyboardHeight = useKeyboardHeight(); const [isLoading, setIsLoading] = React.useState(false); const [isLoadingServerOptions, setIsLoadingServerOptions] = React.useState(true); const [locations, setLocations] = React.useState([]); const [selectedServer, setSelectedServer] = React.useState(CUSTOM_SERVER_VALUE); const setUrl = useServerUrlStore((s) => s.setUrl); const getUrl = useServerUrlStore((s) => s.getUrl); const isAuthenticated = useAuthStore((state) => state.isAuthenticated()); const { control, handleSubmit, setValue, setError, formState: { errors }, } = useForm(); React.useEffect(() => { if (!isOpen) { setIsLoadingServerOptions(true); return undefined; } let isMounted = true; const loadServerOptions = async () => { try { const [currentUrl, systemConfig] = await Promise.all([getUrl(), getSystemConfig()]); const normalizedCurrentUrl = normalizeBaseUrl(currentUrl); const nextLocations = systemConfig.Data?.Locations ?? []; const matchingLocation = nextLocations.find((location) => normalizeBaseUrl(location.ApiUrl) === normalizedCurrentUrl); if (isMounted) { setLocations(nextLocations); setValue('url', matchingLocation ? normalizeInputUrl(matchingLocation.ApiUrl) : normalizedCurrentUrl); setSelectedServer(matchingLocation?.Name ?? CUSTOM_SERVER_VALUE); } } catch (error) { logger.error({ message: 'Failed to load system config for server URLs', context: { error }, }); // The fallback read can fail the same way; it must not escape the effect as an unhandled rejection. let currentUrl = ''; try { currentUrl = await getUrl(); } catch (urlError) { logger.error({ message: 'Failed to read the stored server URL', context: { error: urlError }, }); } if (isMounted) { setLocations([]); setValue('url', normalizeBaseUrl(currentUrl)); setSelectedServer(CUSTOM_SERVER_VALUE); } } finally { if (isMounted) { setIsLoadingServerOptions(false); } } }; loadServerOptions(); return () => { isMounted = false; }; }, [isOpen, setValue, getUrl]); const onFormSubmit = async (data: ServerUrlForm) => { try { setIsLoading(true); const selectedLocation = locations.find((location) => location.Name === selectedServer); const resolvedBaseUrl = selectedServer === CUSTOM_SERVER_VALUE ? data.url : (selectedLocation?.ApiUrl ?? data.url); const normalizedResolvedBaseUrl = normalizeBaseUrl(resolvedBaseUrl); await setUrl(buildApiUrl(normalizedResolvedBaseUrl)); if (isAuthenticated && onUrlChanged) { await onUrlChanged(); } logger.info({ message: 'Server URL updated successfully', context: { url: normalizedResolvedBaseUrl }, }); onClose(); } catch (error) { logger.error({ message: 'Failed to update server URL', context: { error }, }); setError('root', { message: error instanceof Error ? error.message : t('common.error'), }); } finally { setIsLoading(false); } }; const handleServerChange = useCallback( (nextServer: string) => { setSelectedServer(nextServer); if (nextServer === CUSTOM_SERVER_VALUE) { return; } const selectedLocation = locations.find((location) => location.Name === nextServer); if (selectedLocation) { setValue('url', normalizeInputUrl(selectedLocation.ApiUrl)); } }, [locations, setValue] ); const isCustomSelected = selectedServer === CUSTOM_SERVER_VALUE; const selectedServerLabel = isCustomSelected ? t('settings.custom') : locations.find((location) => location.Name === selectedServer)?.Name; return ( {/* Single sanctioned keyboard mechanism for sheets: pad the sheet by the keyboard height so the server URL field and the save buttons stay visible. Never nest a KeyboardAvoidingView (or stack a second inset adjuster) here — see use-keyboard-height.ts. */} {t('settings.server')} {isLoadingServerOptions ? (
{t('loading.loadingData')}
) : ( )}
{t('settings.server_url')} { if (!isCustomSelected) { return true; } if (!value) { return t('form.required'); } return URL_PATTERN.test(value) ? true : t('form.invalid_url'); }, }} render={({ field: { onChange, value } }) => ( )} /> {errors.url?.message}
{t('settings.server_url_note')}
{errors.root?.message ? ( {errors.root.message} ) : null}
); }