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) {