diff --git a/js/app/components/login/login-form.tsx b/js/app/components/login/login-form.tsx new file mode 100644 index 000000000..b44fbeeb4 --- /dev/null +++ b/js/app/components/login/login-form.tsx @@ -0,0 +1,292 @@ +import { + Button, + Input, + Loader, + Text, + useTheme, + zero, +} from "@streamplace/components"; +import useActorTypeahead from "hooks/useActorTypeahead"; +import { + ArrowRightToLine, + AtSign, + CornerDownRight, + Info, +} from "lucide-react-native"; +import { useEffect, useMemo, useState } from "react"; +import { Alert, Image, Linking, Platform, Pressable, View } from "react-native"; +import { useStore } from "store"; +import { useLogin } from "store/hooks"; + +interface LoginFormProps { + onSuccess?: () => void; +} + +export default function LoginForm({ onSuccess }: LoginFormProps) { + const { theme } = useTheme(); + const loginAction = useStore((state) => state.login); + const openLoginLink = useStore((state) => state.openLoginLink); + const loginState = useLogin(); + const [handle, setHandle] = useState(""); + const [imageLoading, setImageLoading] = useState(false); + const { actors } = useActorTypeahead(handle); + + const filteredActors = useMemo( + () => actors.filter((actor) => actor.handle.startsWith(handle)), + [actors, handle], + ); + + const suggestion = useMemo( + () => + filteredActors.length > 0 && + handle.length >= 3 && + filteredActors[0].handle.startsWith(handle) + ? filteredActors[0] + : null, + [filteredActors], + ); + + const completionText = useMemo( + () => + suggestion && suggestion.handle + ? suggestion.handle.slice(handle.length) + : null, + [suggestion, handle], + ); + + const avatarUri = useMemo(() => suggestion?.avatar, [suggestion?.avatar]); + + const submit = () => { + let clean = handle; + if (handle.startsWith("@")) clean = handle.slice(1); + loginAction(clean, openLoginLink); + }; + + const acceptSuggestion = () => { + if (suggestion) { + setHandle(suggestion.handle); + } + }; + + const onSignup = () => { + loginAction("https://bsky.social", openLoginLink); + }; + + const isMobile = Platform.OS === "ios" || Platform.OS === "android"; + + const onKeyPress = (e: any) => { + if (e.nativeEvent.key === "Enter") { + if (completionText && isMobile) { + e.preventDefault(); + acceptSuggestion(); + } else if (!completionText) { + submit(); + } + } else if (e.nativeEvent.key === "Tab" && completionText) { + e.preventDefault(); + acceptSuggestion(); + } else if (e.nativeEvent.key === "ArrowRight" && completionText) { + const input = e.target; + if (input.selectionStart === handle.length) { + e.preventDefault(); + acceptSuggestion(); + } + } else if (e.nativeEvent.key === " " && completionText) { + e.preventDefault(); + acceptSuggestion(); + } + }; + + useEffect(() => { + if (loginState?.error) { + Alert.alert("Login error", loginState.error); + } + }, [loginState?.error]); + + return ( + <> + + + Sign in using your handle on the AT Protocol + + { + const u = new URL( + "https://atproto.academy/docs/Authentication/why", + ); + Linking.openURL(u.toString()); + }} + > + + + + (e.g. your Bluesky handle) + + + + + Handle + + {completionText && suggestion?.handle !== handle ? ( + + + + {handle} + + {completionText} + + {isMobile ? ( + + ) : ( + + )} + + ) : ( + <> + )} + + {avatarUri ? ( + + {imageLoading && ( + + + + )} + setImageLoading(true)} + onLoad={() => setImageLoading(false)} + onError={() => setImageLoading(false)} + /> + + ) : ( + + + + )} + + + setHandle( + text + .toLowerCase() + .replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, "") + .trim(), + ) + } + onKeyPress={onKeyPress} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + placeholderTextColor="#666" + containerStyle={{ + marginLeft: 28 + 8, + }} + /> + + + + + + + + + ); +} diff --git a/js/app/components/login/login-modal.tsx b/js/app/components/login/login-modal.tsx index 61d7b8548..74600a42c 100644 --- a/js/app/components/login/login-modal.tsx +++ b/js/app/components/login/login-modal.tsx @@ -1,18 +1,7 @@ -import { Button, Input, Text, useTheme, zero } from "@streamplace/components"; -import useActorTypeahead from "hooks/useActorTypeahead"; -import { ArrowRightToLine, AtSign, Info, X } from "lucide-react-native"; -import { useEffect, useState } from "react"; -import { - Alert, - Image, - Linking, - Modal, - Pressable, - TouchableOpacity, - View, -} from "react-native"; -import { useStore } from "store"; -import { useLogin } from "store/hooks"; +import { Text, useTheme, zero } from "@streamplace/components"; +import { X } from "lucide-react-native"; +import { Modal, Pressable, TouchableOpacity, View } from "react-native"; +import LoginForm from "./login-form"; interface LoginModalProps { visible: boolean; @@ -21,64 +10,6 @@ interface LoginModalProps { export default function LoginModal({ visible, onClose }: LoginModalProps) { const { theme } = useTheme(); - const loginAction = useStore((state) => state.login); - const openLoginLink = useStore((state) => state.openLoginLink); - const loginState = useLogin(); - const [handle, setHandle] = useState(""); - const { actors } = useActorTypeahead(handle); - - const filteredActors = actors.filter((actor) => - actor.handle.startsWith(handle), - ); - - const suggestion = - filteredActors.length > 0 && - handle.length >= 3 && - filteredActors[0].handle.startsWith(handle) - ? filteredActors[0] - : null; - - const completionText = - suggestion && suggestion.handle - ? suggestion.handle.slice(handle.length) - : null; - - const submit = () => { - let clean = handle; - if (handle.startsWith("@")) clean = handle.slice(1); - loginAction(clean, openLoginLink); - }; - - const acceptSuggestion = () => { - if (suggestion) { - setHandle(suggestion.handle); - } - }; - - const onSignup = () => { - loginAction("https://bsky.social", openLoginLink); - }; - - const onKeyPress = (e: any) => { - if (e.nativeEvent.key === "Enter") { - submit(); - } else if (e.nativeEvent.key === "Tab" && completionText) { - e.preventDefault(); - acceptSuggestion(); - } else if (e.nativeEvent.key === "ArrowRight" && completionText) { - const input = e.target; - if (input.selectionStart === handle.length) { - e.preventDefault(); - acceptSuggestion(); - } - } - }; - - useEffect(() => { - if (loginState?.error) { - Alert.alert("Login error", loginState.error); - } - }, [loginState?.error]); return ( - - - Sign in using your handle on the AT Protocol - - { - const u = new URL( - "https://atproto.academy/docs/Authentication/why", - ); - Linking.openURL(u.toString()); - }} - > - - - - (e.g. your Bluesky handle) - - - - - Handle - - {completionText && ( - - - - {handle} - - {completionText} - - - - )} - - {suggestion?.avatar ? ( - - ) : ( - - - - )} - - - setHandle( - text - .toLowerCase() - .replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, "") - .trim(), - ) - } - onKeyPress={onKeyPress} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - placeholderTextColor="#666" - containerStyle={{ - marginLeft: 28 + 8, - }} - /> - - - - - - - + diff --git a/js/app/components/login/login.tsx b/js/app/components/login/login.tsx index 5665e814a..c75ee5745 100644 --- a/js/app/components/login/login.tsx +++ b/js/app/components/login/login.tsx @@ -1,46 +1,20 @@ import { useNavigation } from "@react-navigation/native"; -import { Button, storage, Text, useTheme, zero } from "@streamplace/components"; +import { storage, Text, useTheme, zero } from "@streamplace/components"; import { Redirect } from "components/aqlink"; import Loading from "components/loading/loading"; -import useActorTypeahead from "hooks/useActorTypeahead"; -import { Info } from "lucide-react-native"; import { useEffect, useState } from "react"; -import { - ActivityIndicator, - Alert, - KeyboardAvoidingView, - Linking, - Platform, - Pressable, - ScrollView, - TextInput, - View, -} from "react-native"; +import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; import { useStore } from "store"; -import { useIsReady, useLogin, useUserProfile } from "store/hooks"; +import { useIsReady, useUserProfile } from "store/hooks"; import { navigateToRoute } from "../../utils/navigation"; +import LoginForm from "./login-form"; export default function Login() { const { theme } = useTheme(); - const loginAction = useStore((state) => state.login); - const openLoginLink = useStore((state) => state.openLoginLink); const closeLoginModal = useStore((state) => state.closeLoginModal); const userProfile = useUserProfile(); - const loginState = useLogin(); const navigation = useNavigation(); - const [handle, setHandle] = useState(""); const isReady = useIsReady(); - const { actors } = useActorTypeahead(handle); - - const suggestion = - actors.length > 0 && - handle.length >= 3 && - actors[0].handle.startsWith(handle) - ? actors[0].handle - : null; - - const completionText = suggestion ? suggestion.slice(handle.length) : null; - // null: no return route, undefined: hasn't checked yet const [localReturnRoute, setLocalReturnRoute] = useState< | { name: string; @@ -71,43 +45,6 @@ export default function Login() { }); }, [navigation, closeLoginModal]); - const submit = () => { - let clean = handle; - if (handle.startsWith("@")) clean = handle.slice(1); - loginAction(clean, openLoginLink); - }; - - const acceptSuggestion = () => { - if (suggestion) { - setHandle(suggestion); - } - }; - - const onSignup = () => { - loginAction("https://bsky.social", openLoginLink); - }; - - const onKeyPress = (e: any) => { - if (e.nativeEvent.key === "Enter") { - submit(); - } else if (e.nativeEvent.key === "Tab" && completionText) { - e.preventDefault(); - acceptSuggestion(); - } else if (e.nativeEvent.key === "ArrowRight" && completionText) { - const input = e.target; - if (input.selectionStart === handle.length) { - e.preventDefault(); - acceptSuggestion(); - } - } - }; - - useEffect(() => { - if (loginState?.error) { - Alert.alert("Login error", loginState.error); - } - }, [loginState?.error]); - if (!isReady || localReturnRoute === undefined) { return ( Log in - - - Sign in using your handle on the AT Protocol - - { - const u = new URL( - "https://atproto.academy/docs/Authentication/why", - ); - Linking.openURL(u.toString()); - }} - > - - - - (e.g. your Bluesky handle) - - - - Handle - - {completionText && ( - - {handle} - {completionText} - - )} - - setHandle( - text - .toLowerCase() - .replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, "") - .trim(), - ) - } - onKeyPress={onKeyPress} - style={[ - { - backgroundColor: "#1a1a1a", - borderWidth: 1, - borderColor: "#333", - borderRadius: 8, - padding: 12, - color: "white", - position: "relative", - zIndex: 2, - }, - ]} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - placeholderTextColor="#666" - /> - - - - - - + diff --git a/js/app/hooks/useActorTypeahead.tsx b/js/app/hooks/useActorTypeahead.tsx index ae6159907..e54405af9 100644 --- a/js/app/hooks/useActorTypeahead.tsx +++ b/js/app/hooks/useActorTypeahead.tsx @@ -26,6 +26,7 @@ export default function useActorTypeahead(query: string): TypeaheadResult { const abortControllerRef = useRef(null); const lastRequestTimeRef = useRef(0); const debounceTimerRef = useRef(null); + const actorsRef = useRef([]); useEffect(() => { if (debounceTimerRef.current) { @@ -78,7 +79,24 @@ export default function useActorTypeahead(query: string): TypeaheadResult { const data = await response.json(); if (!controller.signal.aborted) { - setActors(data.actors || []); + const newActors = data.actors || []; + + // check if actors actually changed + const actorsChanged = + newActors.length !== actorsRef.current.length || + newActors.some( + (actor: Actor, i: number) => + actor.did !== actorsRef.current[i]?.did || + actor.avatar !== actorsRef.current[i]?.avatar, + ); + + if (actorsChanged) { + actorsRef.current = newActors; + setActors(newActors); + } else { + // keep the same reference to prevent re-renders + setActors(actorsRef.current); + } setLoading(false); } } catch (err: any) {