Live video on the AT Protocol

lil styling and refactor

+321 -390
+292
js/app/components/login/login-form.tsx
··· 1 + import { 2 + Button, 3 + Input, 4 + Loader, 5 + Text, 6 + useTheme, 7 + zero, 8 + } from "@streamplace/components"; 9 + import useActorTypeahead from "hooks/useActorTypeahead"; 10 + import { 11 + ArrowRightToLine, 12 + AtSign, 13 + CornerDownRight, 14 + Info, 15 + } from "lucide-react-native"; 16 + import { useEffect, useMemo, useState } from "react"; 17 + import { Alert, Image, Linking, Platform, Pressable, View } from "react-native"; 18 + import { useStore } from "store"; 19 + import { useLogin } from "store/hooks"; 20 + 21 + interface LoginFormProps { 22 + onSuccess?: () => void; 23 + } 24 + 25 + export default function LoginForm({ onSuccess }: LoginFormProps) { 26 + const { theme } = useTheme(); 27 + const loginAction = useStore((state) => state.login); 28 + const openLoginLink = useStore((state) => state.openLoginLink); 29 + const loginState = useLogin(); 30 + const [handle, setHandle] = useState(""); 31 + const [imageLoading, setImageLoading] = useState(false); 32 + const { actors } = useActorTypeahead(handle); 33 + 34 + const filteredActors = useMemo( 35 + () => actors.filter((actor) => actor.handle.startsWith(handle)), 36 + [actors, handle], 37 + ); 38 + 39 + const suggestion = useMemo( 40 + () => 41 + filteredActors.length > 0 && 42 + handle.length >= 3 && 43 + filteredActors[0].handle.startsWith(handle) 44 + ? filteredActors[0] 45 + : null, 46 + [filteredActors], 47 + ); 48 + 49 + const completionText = useMemo( 50 + () => 51 + suggestion && suggestion.handle 52 + ? suggestion.handle.slice(handle.length) 53 + : null, 54 + [suggestion, handle], 55 + ); 56 + 57 + const avatarUri = useMemo(() => suggestion?.avatar, [suggestion?.avatar]); 58 + 59 + const submit = () => { 60 + let clean = handle; 61 + if (handle.startsWith("@")) clean = handle.slice(1); 62 + loginAction(clean, openLoginLink); 63 + }; 64 + 65 + const acceptSuggestion = () => { 66 + if (suggestion) { 67 + setHandle(suggestion.handle); 68 + } 69 + }; 70 + 71 + const onSignup = () => { 72 + loginAction("https://bsky.social", openLoginLink); 73 + }; 74 + 75 + const isMobile = Platform.OS === "ios" || Platform.OS === "android"; 76 + 77 + const onKeyPress = (e: any) => { 78 + if (e.nativeEvent.key === "Enter") { 79 + if (completionText && isMobile) { 80 + e.preventDefault(); 81 + acceptSuggestion(); 82 + } else if (!completionText) { 83 + submit(); 84 + } 85 + } else if (e.nativeEvent.key === "Tab" && completionText) { 86 + e.preventDefault(); 87 + acceptSuggestion(); 88 + } else if (e.nativeEvent.key === "ArrowRight" && completionText) { 89 + const input = e.target; 90 + if (input.selectionStart === handle.length) { 91 + e.preventDefault(); 92 + acceptSuggestion(); 93 + } 94 + } else if (e.nativeEvent.key === " " && completionText) { 95 + e.preventDefault(); 96 + acceptSuggestion(); 97 + } 98 + }; 99 + 100 + useEffect(() => { 101 + if (loginState?.error) { 102 + Alert.alert("Login error", loginState.error); 103 + } 104 + }, [loginState?.error]); 105 + 106 + return ( 107 + <> 108 + <View 109 + style={[ 110 + zero.layout.flex.row, 111 + { flexWrap: "wrap" }, 112 + zero.gap.all[1], 113 + zero.mb[4], 114 + ]} 115 + > 116 + <Text style={[{ color: theme.colors.textMuted }]}> 117 + Sign in using your handle on the AT Protocol 118 + </Text> 119 + <Pressable 120 + onPress={() => { 121 + const u = new URL( 122 + "https://atproto.academy/docs/Authentication/why", 123 + ); 124 + Linking.openURL(u.toString()); 125 + }} 126 + > 127 + <Info size={16} style={{ paddingTop: 4 }} color={theme.colors.ring} /> 128 + </Pressable> 129 + <Text style={[{ color: theme.colors.textMuted }]}> 130 + (e.g. your Bluesky handle) 131 + </Text> 132 + </View> 133 + 134 + <View style={[zero.mb[4], { position: "relative" }]}> 135 + <Text style={[{ color: "#aaa", marginBottom: 8 }]}>Handle</Text> 136 + <View style={{ position: "relative" }}> 137 + {completionText && suggestion?.handle !== handle ? ( 138 + <View 139 + style={[ 140 + { 141 + position: "absolute", 142 + left: 13 + 28 + 8, 143 + top: 12, 144 + zIndex: 1000000, 145 + pointerEvents: "none", 146 + }, 147 + zero.layout.flex.row, 148 + zero.layout.flex.alignCenter, 149 + zero.gap.all[1], 150 + ]} 151 + > 152 + <Text 153 + style={[ 154 + { 155 + color: "#555", 156 + pointerEvents: "none", 157 + zIndex: 1000000, 158 + fontSize: 16, 159 + }, 160 + ]} 161 + > 162 + <Text 163 + style={{ 164 + opacity: 0.2, 165 + fontSize: 16, 166 + }} 167 + > 168 + {handle} 169 + </Text> 170 + {completionText} 171 + </Text> 172 + {isMobile ? ( 173 + <CornerDownRight 174 + height={18} 175 + color="#555" 176 + style={{ 177 + paddingBottom: 1, 178 + }} 179 + /> 180 + ) : ( 181 + <ArrowRightToLine 182 + height={18} 183 + color="#555" 184 + style={{ 185 + paddingBottom: 1, 186 + }} 187 + /> 188 + )} 189 + </View> 190 + ) : ( 191 + <></> 192 + )} 193 + <View 194 + style={[ 195 + zero.layout.position.absolute, 196 + zero.layout.flex.row, 197 + { zIndex: 32, top: 8 }, 198 + ]} 199 + > 200 + {avatarUri ? ( 201 + <View 202 + style={{ 203 + width: 28, 204 + height: 28, 205 + borderRadius: 900, 206 + justifyContent: "center", 207 + alignItems: "center", 208 + }} 209 + > 210 + {imageLoading && ( 211 + <View 212 + style={{ 213 + position: "absolute", 214 + zIndex: 1, 215 + }} 216 + > 217 + <Loader /> 218 + </View> 219 + )} 220 + <Image 221 + key={avatarUri} 222 + source={{ uri: avatarUri }} 223 + style={{ 224 + width: 32, 225 + height: 32, 226 + borderRadius: 900, 227 + opacity: suggestion?.handle === handle ? 1 : 0.5, 228 + }} 229 + onLayout={() => setImageLoading(true)} 230 + onLoad={() => setImageLoading(false)} 231 + onError={() => setImageLoading(false)} 232 + /> 233 + </View> 234 + ) : ( 235 + <View 236 + style={{ 237 + width: 28, 238 + height: 28, 239 + borderRadius: 900, 240 + justifyContent: "center", 241 + alignItems: "center", 242 + }} 243 + > 244 + <AtSign size={20} color="#eee" /> 245 + </View> 246 + )} 247 + </View> 248 + <Input 249 + value={handle} 250 + onChangeText={(text) => 251 + setHandle( 252 + text 253 + .toLowerCase() 254 + .replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, "") 255 + .trim(), 256 + ) 257 + } 258 + onKeyPress={onKeyPress} 259 + autoCapitalize="none" 260 + autoCorrect={false} 261 + keyboardType="url" 262 + placeholderTextColor="#666" 263 + containerStyle={{ 264 + marginLeft: 28 + 8, 265 + }} 266 + /> 267 + </View> 268 + </View> 269 + 270 + <View 271 + style={[ 272 + zero.layout.flex.row, 273 + { justifyContent: "flex-end", zIndex: -32 }, 274 + zero.gap.all[3], 275 + ]} 276 + > 277 + <Button width="min" onPress={() => onSignup()} variant="ghost"> 278 + <Text style={[{ color: "white" }]}>Sign Up on Bluesky</Text> 279 + </Button> 280 + <Button 281 + onPress={submit} 282 + disabled={loginState.loading} 283 + style={[zero.px[6]]} 284 + width="min" 285 + loading={loginState.loading} 286 + > 287 + <Text style={[{ color: "white" }]}>Log in</Text> 288 + </Button> 289 + </View> 290 + </> 291 + ); 292 + }
+5 -221
js/app/components/login/login-modal.tsx
··· 1 - import { Button, Input, Text, useTheme, zero } from "@streamplace/components"; 2 - import useActorTypeahead from "hooks/useActorTypeahead"; 3 - import { ArrowRightToLine, AtSign, Info, X } from "lucide-react-native"; 4 - import { useEffect, useState } from "react"; 5 - import { 6 - Alert, 7 - Image, 8 - Linking, 9 - Modal, 10 - Pressable, 11 - TouchableOpacity, 12 - View, 13 - } from "react-native"; 14 - import { useStore } from "store"; 15 - import { useLogin } from "store/hooks"; 1 + import { Text, useTheme, zero } from "@streamplace/components"; 2 + import { X } from "lucide-react-native"; 3 + import { Modal, Pressable, TouchableOpacity, View } from "react-native"; 4 + import LoginForm from "./login-form"; 16 5 17 6 interface LoginModalProps { 18 7 visible: boolean; ··· 21 10 22 11 export default function LoginModal({ visible, onClose }: LoginModalProps) { 23 12 const { theme } = useTheme(); 24 - const loginAction = useStore((state) => state.login); 25 - const openLoginLink = useStore((state) => state.openLoginLink); 26 - const loginState = useLogin(); 27 - const [handle, setHandle] = useState(""); 28 - const { actors } = useActorTypeahead(handle); 29 - 30 - const filteredActors = actors.filter((actor) => 31 - actor.handle.startsWith(handle), 32 - ); 33 - 34 - const suggestion = 35 - filteredActors.length > 0 && 36 - handle.length >= 3 && 37 - filteredActors[0].handle.startsWith(handle) 38 - ? filteredActors[0] 39 - : null; 40 - 41 - const completionText = 42 - suggestion && suggestion.handle 43 - ? suggestion.handle.slice(handle.length) 44 - : null; 45 - 46 - const submit = () => { 47 - let clean = handle; 48 - if (handle.startsWith("@")) clean = handle.slice(1); 49 - loginAction(clean, openLoginLink); 50 - }; 51 - 52 - const acceptSuggestion = () => { 53 - if (suggestion) { 54 - setHandle(suggestion.handle); 55 - } 56 - }; 57 - 58 - const onSignup = () => { 59 - loginAction("https://bsky.social", openLoginLink); 60 - }; 61 - 62 - const onKeyPress = (e: any) => { 63 - if (e.nativeEvent.key === "Enter") { 64 - submit(); 65 - } else if (e.nativeEvent.key === "Tab" && completionText) { 66 - e.preventDefault(); 67 - acceptSuggestion(); 68 - } else if (e.nativeEvent.key === "ArrowRight" && completionText) { 69 - const input = e.target; 70 - if (input.selectionStart === handle.length) { 71 - e.preventDefault(); 72 - acceptSuggestion(); 73 - } 74 - } 75 - }; 76 - 77 - useEffect(() => { 78 - if (loginState?.error) { 79 - Alert.alert("Login error", loginState.error); 80 - } 81 - }, [loginState?.error]); 82 13 83 14 return ( 84 15 <Modal ··· 134 65 </TouchableOpacity> 135 66 </View> 136 67 137 - <View 138 - style={[ 139 - { flexWrap: "wrap", flexDirection: "row" }, 140 - zero.gap.all[1], 141 - zero.mb[4], 142 - ]} 143 - > 144 - <Text style={[{ color: theme.colors.textMuted }]}> 145 - Sign in using your handle on the AT Protocol 146 - </Text> 147 - <Pressable 148 - onPress={() => { 149 - const u = new URL( 150 - "https://atproto.academy/docs/Authentication/why", 151 - ); 152 - Linking.openURL(u.toString()); 153 - }} 154 - > 155 - <Info 156 - size={16} 157 - style={{ paddingTop: 4 }} 158 - color={theme.colors.ring} 159 - /> 160 - </Pressable> 161 - <Text style={[{ color: theme.colors.textMuted }]}> 162 - (e.g. your Bluesky handle) 163 - </Text> 164 - </View> 165 - 166 - <View style={[zero.mb[4], { position: "relative" }]}> 167 - <Text style={[{ color: "#aaa", marginBottom: 8 }]}>Handle</Text> 168 - <View style={{ position: "relative" }}> 169 - {completionText && ( 170 - <View 171 - style={[ 172 - { 173 - position: "absolute", 174 - left: 13 + 28 + 8, 175 - top: 12, 176 - zIndex: 1000000, 177 - // clickthroughable 178 - pointerEvents: "none", 179 - }, 180 - zero.layout.flex.row, 181 - zero.layout.flex.alignCenter, 182 - zero.gap.all[1], 183 - ]} 184 - > 185 - <Text 186 - style={[ 187 - { 188 - color: "#555", 189 - pointerEvents: "none", 190 - zIndex: 1000000, 191 - fontSize: 16, 192 - }, 193 - ]} 194 - > 195 - <Text 196 - style={{ 197 - opacity: 0.2, 198 - fontSize: 16, 199 - }} 200 - > 201 - {handle} 202 - </Text> 203 - {completionText} 204 - </Text> 205 - <ArrowRightToLine 206 - height={18} 207 - color="#555" 208 - style={{ 209 - paddingBottom: 1, 210 - }} 211 - /> 212 - </View> 213 - )} 214 - <View 215 - style={{ 216 - position: "absolute", 217 - flexDirection: "row", 218 - zIndex: 32, 219 - top: 8, 220 - }} 221 - > 222 - {suggestion?.avatar ? ( 223 - <Image 224 - source={{ uri: suggestion.avatar }} 225 - style={{ 226 - width: 28, 227 - height: 28, 228 - borderRadius: 900, 229 - opacity: suggestion.handle === handle ? 1 : 0.7, 230 - }} 231 - /> 232 - ) : ( 233 - <View 234 - style={{ 235 - width: 28, 236 - height: 28, 237 - borderRadius: 900, 238 - }} 239 - > 240 - <AtSign size={28} color="#eee" /> 241 - </View> 242 - )} 243 - </View> 244 - <Input 245 - value={handle} 246 - onChangeText={(text) => 247 - setHandle( 248 - text 249 - .toLowerCase() 250 - .replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, "") 251 - .trim(), 252 - ) 253 - } 254 - onKeyPress={onKeyPress} 255 - autoCapitalize="none" 256 - autoCorrect={false} 257 - keyboardType="url" 258 - placeholderTextColor="#666" 259 - containerStyle={{ 260 - marginLeft: 28 + 8, 261 - }} 262 - /> 263 - </View> 264 - </View> 265 - 266 - <View 267 - style={[ 268 - { flexDirection: "row", justifyContent: "flex-end", zIndex: -32 }, 269 - zero.gap.all[3], 270 - ]} 271 - > 272 - <Button width="min" onPress={() => onSignup()} variant="ghost"> 273 - <Text style={[{ color: "white" }]}>Sign Up on Bluesky</Text> 274 - </Button> 275 - <Button 276 - onPress={submit} 277 - disabled={loginState.loading} 278 - style={[zero.px[6]]} 279 - width="min" 280 - loading={loginState.loading} 281 - > 282 - <Text style={[{ color: "white" }]}>Log in</Text> 283 - </Button> 284 - </View> 68 + <LoginForm onSuccess={onClose} /> 285 69 </Pressable> 286 70 </View> 287 71 </Modal>
+5 -168
js/app/components/login/login.tsx
··· 1 1 import { useNavigation } from "@react-navigation/native"; 2 - import { Button, storage, Text, useTheme, zero } from "@streamplace/components"; 2 + import { storage, Text, useTheme, zero } from "@streamplace/components"; 3 3 import { Redirect } from "components/aqlink"; 4 4 import Loading from "components/loading/loading"; 5 - import useActorTypeahead from "hooks/useActorTypeahead"; 6 - import { Info } from "lucide-react-native"; 7 5 import { useEffect, useState } from "react"; 8 - import { 9 - ActivityIndicator, 10 - Alert, 11 - KeyboardAvoidingView, 12 - Linking, 13 - Platform, 14 - Pressable, 15 - ScrollView, 16 - TextInput, 17 - View, 18 - } from "react-native"; 6 + import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; 19 7 import { useStore } from "store"; 20 - import { useIsReady, useLogin, useUserProfile } from "store/hooks"; 8 + import { useIsReady, useUserProfile } from "store/hooks"; 21 9 import { navigateToRoute } from "../../utils/navigation"; 10 + import LoginForm from "./login-form"; 22 11 23 12 export default function Login() { 24 13 const { theme } = useTheme(); 25 - const loginAction = useStore((state) => state.login); 26 - const openLoginLink = useStore((state) => state.openLoginLink); 27 14 const closeLoginModal = useStore((state) => state.closeLoginModal); 28 15 const userProfile = useUserProfile(); 29 - const loginState = useLogin(); 30 16 const navigation = useNavigation(); 31 - const [handle, setHandle] = useState(""); 32 17 const isReady = useIsReady(); 33 - const { actors } = useActorTypeahead(handle); 34 - 35 - const suggestion = 36 - actors.length > 0 && 37 - handle.length >= 3 && 38 - actors[0].handle.startsWith(handle) 39 - ? actors[0].handle 40 - : null; 41 - 42 - const completionText = suggestion ? suggestion.slice(handle.length) : null; 43 - // null: no return route, undefined: hasn't checked yet 44 18 const [localReturnRoute, setLocalReturnRoute] = useState< 45 19 | { 46 20 name: string; ··· 71 45 }); 72 46 }, [navigation, closeLoginModal]); 73 47 74 - const submit = () => { 75 - let clean = handle; 76 - if (handle.startsWith("@")) clean = handle.slice(1); 77 - loginAction(clean, openLoginLink); 78 - }; 79 - 80 - const acceptSuggestion = () => { 81 - if (suggestion) { 82 - setHandle(suggestion); 83 - } 84 - }; 85 - 86 - const onSignup = () => { 87 - loginAction("https://bsky.social", openLoginLink); 88 - }; 89 - 90 - const onKeyPress = (e: any) => { 91 - if (e.nativeEvent.key === "Enter") { 92 - submit(); 93 - } else if (e.nativeEvent.key === "Tab" && completionText) { 94 - e.preventDefault(); 95 - acceptSuggestion(); 96 - } else if (e.nativeEvent.key === "ArrowRight" && completionText) { 97 - const input = e.target; 98 - if (input.selectionStart === handle.length) { 99 - e.preventDefault(); 100 - acceptSuggestion(); 101 - } 102 - } 103 - }; 104 - 105 - useEffect(() => { 106 - if (loginState?.error) { 107 - Alert.alert("Login error", loginState.error); 108 - } 109 - }, [loginState?.error]); 110 - 111 48 if (!isReady || localReturnRoute === undefined) { 112 49 return ( 113 50 <View ··· 166 103 <Text style={[{ fontSize: 36, fontWeight: "200", color: "white" }]}> 167 104 Log in 168 105 </Text> 169 - <View 170 - style={[ 171 - { flexWrap: "wrap", flexDirection: "row" }, 172 - zero.gap.all[1], 173 - ]} 174 - > 175 - <Text style={[{ color: theme.colors.textMuted }]}> 176 - Sign in using your handle on the AT Protocol 177 - </Text> 178 - <Pressable 179 - onPress={() => { 180 - const u = new URL( 181 - "https://atproto.academy/docs/Authentication/why", 182 - ); 183 - Linking.openURL(u.toString()); 184 - }} 185 - > 186 - <Info 187 - size={16} 188 - style={{ paddingTop: 4 }} 189 - color={theme.colors.ring} 190 - /> 191 - </Pressable> 192 - <Text style={[{ color: theme.colors.textMuted }]}> 193 - (e.g. your Bluesky handle) 194 - </Text> 195 - </View> 196 - <View style={[zero.pb[2], { position: "relative" }]}> 197 - <Text style={[{ color: "#aaa" }]}>Handle</Text> 198 - <View style={{ position: "relative" }}> 199 - {completionText && ( 200 - <Text 201 - style={[ 202 - { 203 - position: "absolute", 204 - left: 12, 205 - top: 12, 206 - color: "#555", 207 - pointerEvents: "none", 208 - zIndex: 1, 209 - }, 210 - ]} 211 - > 212 - <Text style={{ opacity: 0 }}>{handle}</Text> 213 - {completionText} 214 - </Text> 215 - )} 216 - <TextInput 217 - value={handle} 218 - onChangeText={(text) => 219 - setHandle( 220 - text 221 - .toLowerCase() 222 - .replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, "") 223 - .trim(), 224 - ) 225 - } 226 - onKeyPress={onKeyPress} 227 - style={[ 228 - { 229 - backgroundColor: "#1a1a1a", 230 - borderWidth: 1, 231 - borderColor: "#333", 232 - borderRadius: 8, 233 - padding: 12, 234 - color: "white", 235 - position: "relative", 236 - zIndex: 2, 237 - }, 238 - ]} 239 - autoCapitalize="none" 240 - autoCorrect={false} 241 - keyboardType="url" 242 - placeholderTextColor="#666" 243 - /> 244 - </View> 245 - </View> 246 - <View 247 - style={[ 248 - { flexDirection: "row", justifyContent: "flex-end" }, 249 - zero.gap.all[3], 250 - ]} 251 - > 252 - <Button width="min" onPress={() => onSignup()} variant="ghost"> 253 - <Text style={[{ color: "white" }]}>Sign Up on Bluesky</Text> 254 - </Button> 255 - <Button 256 - onPress={submit} 257 - disabled={loginState.loading} 258 - style={[zero.px[6]]} 259 - width="min" 260 - > 261 - <Text style={[{ color: "white" }]}> 262 - {loginState.loading ? ( 263 - <ActivityIndicator size="small" color="white" /> 264 - ) : ( 265 - "Log in" 266 - )} 267 - </Text> 268 - </Button> 269 - </View> 106 + <LoginForm /> 270 107 </View> 271 108 </View> 272 109 </ScrollView>
+19 -1
js/app/hooks/useActorTypeahead.tsx
··· 26 26 const abortControllerRef = useRef<AbortController | null>(null); 27 27 const lastRequestTimeRef = useRef<number>(0); 28 28 const debounceTimerRef = useRef<NodeJS.Timeout | null>(null); 29 + const actorsRef = useRef<Actor[]>([]); 29 30 30 31 useEffect(() => { 31 32 if (debounceTimerRef.current) { ··· 78 79 const data = await response.json(); 79 80 80 81 if (!controller.signal.aborted) { 81 - setActors(data.actors || []); 82 + const newActors = data.actors || []; 83 + 84 + // check if actors actually changed 85 + const actorsChanged = 86 + newActors.length !== actorsRef.current.length || 87 + newActors.some( 88 + (actor: Actor, i: number) => 89 + actor.did !== actorsRef.current[i]?.did || 90 + actor.avatar !== actorsRef.current[i]?.avatar, 91 + ); 92 + 93 + if (actorsChanged) { 94 + actorsRef.current = newActors; 95 + setActors(newActors); 96 + } else { 97 + // keep the same reference to prevent re-renders 98 + setActors(actorsRef.current); 99 + } 82 100 setLoading(false); 83 101 } 84 102 } catch (err: any) {