diff --git a/bun.lock b/bun.lock index 97ba4fa2..d9701a70 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "expo-brightness": "~56.0.5", "expo-build-properties": "~56.0.15", "expo-camera": "~56.0.7", + "expo-clipboard": "~56.0.4", "expo-constants": "~56.0.16", "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.16", @@ -955,6 +956,8 @@ "expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="], + "expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="], + "expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="], "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="], diff --git a/components/login/Login.tsx b/components/login/Login.tsx index 7cf5f43f..541e9727 100644 --- a/components/login/Login.tsx +++ b/components/login/Login.tsx @@ -20,10 +20,11 @@ import { Button } from "@/components/Button"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; +import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal"; import { PreviousServersList } from "@/components/PreviousServersList"; import { SaveAccountModal } from "@/components/SaveAccountModal"; import { Colors } from "@/constants/Colors"; -import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; +import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import type { AccountSecurityType, SavedServer, @@ -37,11 +38,13 @@ export const Login: React.FC = () => { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); + const user = useAtomValue(userAtom); const { setServer, login, removeServer, initiateQuickConnect, + stopQuickConnectPolling, loginWithSavedCredential, loginWithPassword, } = useJellyfin(); @@ -64,6 +67,32 @@ export const Login: React.FC = () => { password: _password || "", }); + // Quick Connect code shown in the in-app sheet while polling for authorization + const [quickConnectCode, setQuickConnectCode] = useState(null); + + // Close the code sheet as soon as the session is authorized — the native + // Alert used before had no programmatic dismiss and stayed open after login. + useEffect(() => { + if (user) setQuickConnectCode(null); + }, [user]); + + // Stop Quick Connect polling when leaving the login page (parity with TVLogin) + useEffect(() => { + return () => { + stopQuickConnectPolling(); + }; + }, [stopQuickConnectPolling]); + + // Going back to server selection keeps this component mounted (same screen, + // different state), so the unmount cleanup above doesn't run. Without this a + // code authorized after leaving would silently log the user in later. + useEffect(() => { + if (!api?.basePath) { + stopQuickConnectPolling(); + setQuickConnectCode(null); + } + }, [api?.basePath, stopQuickConnectPolling]); + // Save account state const [saveAccount, setSaveAccount] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false); @@ -146,7 +175,7 @@ export const Login: React.FC = () => { } else { Alert.alert( t("login.connection_failed"), - t("login.an_unexpected_error_occured"), + t("login.an_unexpected_error_occurred"), ); } } finally { @@ -259,15 +288,7 @@ export const Login: React.FC = () => { try { const code = await initiateQuickConnect(); if (code) { - Alert.alert( - t("login.quick_connect"), - t("login.enter_code_to_login", { code: code }), - [ - { - text: t("login.got_it"), - }, - ], - ); + setQuickConnectCode(code); } } catch (_error) { Alert.alert( @@ -402,7 +423,7 @@ export const Login: React.FC = () => { {t("server.enter_url_to_jellyfin_server")} { onSave={handleSaveAccountConfirm} username={pendingLogin?.username || credentials.username} /> + + {/* Dismissing only hides the code — polling continues so the login still + completes if the code is authorized from another device afterwards. */} + setQuickConnectCode(null)} + /> ); }; diff --git a/components/login/QuickConnectCodeModal.tsx b/components/login/QuickConnectCodeModal.tsx new file mode 100644 index 00000000..cc747bcd --- /dev/null +++ b/components/login/QuickConnectCodeModal.tsx @@ -0,0 +1,137 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import { requireOptionalNativeModule } from "expo-modules-core"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toast } from "sonner-native"; +import { Button } from "../Button"; +import { Text } from "../common/Text"; + +interface Props { + /** The Quick Connect code to display, or null when hidden. */ + code: string | null; + onClose: () => void; +} + +/** + * Shows the Quick Connect code while the app polls for authorization. + * In-app sheet instead of a native Alert so it can dismiss itself once the + * session is authorized — a native alert has no programmatic dismiss and + * lingers over the app after login completes. + */ +export const QuickConnectCodeModal: React.FC = ({ code, onClose }) => { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const bottomSheetModalRef = useRef(null); + const snapPoints = useMemo(() => ["50%"], []); + const isPresentedRef = useRef(false); + + // Keep the last code around so the dismiss animation doesn't flash empty + // when the parent clears the code to close the sheet. + const lastCodeRef = useRef(null); + if (code) lastCodeRef.current = code; + + useEffect(() => { + if (code) { + bottomSheetModalRef.current?.present(); + } else if (isPresentedRef.current) { + bottomSheetModalRef.current?.dismiss(); + isPresentedRef.current = false; + } + }, [code]); + + const handleSheetChanges = useCallback( + (index: number) => { + if (index >= 0) { + isPresentedRef.current = true; + } else if (index === -1 && isPresentedRef.current) { + isPresentedRef.current = false; + onClose(); + } + }, + [onClose], + ); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const copyCode = useCallback(async () => { + const value = code ?? lastCodeRef.current; + if (!value) return; + // Builds that don't ship the expo-clipboard native module yet: probe with + // requireOptionalNativeModule (returns null instead of throwing/logging) + // and skip — importing the JS wrapper there would error out. + if (!requireOptionalNativeModule("ExpoClipboard")) return; + const Clipboard = await import("expo-clipboard"); + await Clipboard.setStringAsync(value); + toast.success(t("login.code_copied")); + }, [code, t]); + + return ( + + + + + {t("login.quick_connect")} + + + + {code ?? lastCodeRef.current} + + + + + {t("login.tap_code_to_copy")} + + + {t("login.quick_connect_instructions")} + + + + + + ); +}; diff --git a/package.json b/package.json index 588ada9a..d7570c5e 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "expo-brightness": "~56.0.5", "expo-build-properties": "~56.0.15", "expo-camera": "~56.0.7", + "expo-clipboard": "~56.0.4", "expo-constants": "~56.0.16", "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.16",