Your music, beautifully tracked. All yours. (coming soon) teal.fm
teal-fm atproto

sneaking in a little ui

Natalie B 2bbb0364 b4828586

+278 -65
+231 -30
apps/amethyst/app/auth/login.tsx
··· 1 1 import { Link, Stack, router } from "expo-router"; 2 2 import { AlertCircle, AtSign, Check, ChevronRight } from "lucide-react-native"; 3 - import React, { useState } from "react"; 4 - import { Platform, View } from "react-native"; 3 + import React, { useState, useEffect, useCallback, useRef } from "react"; // Added useCallback, useRef 4 + import { Platform, TextInput, View } from "react-native"; 5 5 import { SafeAreaView } from "react-native-safe-area-context"; 6 6 import { Button } from "@/components/ui/button"; 7 7 import { Input } from "@/components/ui/input"; 8 8 import { Text } from "@/components/ui/text"; 9 9 import { Icon } from "@/lib/icons/iconWithClassName"; 10 - import { cn } from "@/lib/utils"; 10 + import { capFirstLetter, cn } from "@/lib/utils"; 11 11 12 12 import { openAuthSessionAsync } from "expo-web-browser"; 13 13 import { useStore } from "@/stores/mainStore"; 14 + import { resolveFromIdentity } from "@/lib/atp/pid"; 15 + 16 + import Animated, { 17 + useSharedValue, 18 + useAnimatedStyle, 19 + withTiming, 20 + interpolate, 21 + } from "react-native-reanimated"; 22 + import { MaterialCommunityIcons, FontAwesome6 } from "@expo/vector-icons"; 23 + 24 + type Url = URL; 25 + 26 + interface ResolvedIdentity { 27 + pds: Url; 28 + [key: string]: any; 29 + } 30 + 31 + const DEBOUNCE_DELAY = 500; // 500ms debounce delay 14 32 15 33 const LoginScreen = () => { 16 34 const [handle, setHandle] = useState(""); 17 35 const [err, setErr] = useState<string | undefined>(); 18 36 const [isRedirecting, setIsRedirecting] = useState(false); 19 37 const [isLoading, setIsLoading] = useState(false); 38 + const [isSelected, setIsSelected] = useState(false); 39 + 40 + const [pdsUrl, setPdsUrl] = useState<Url | null>(null); 41 + const [isResolvingPds, setIsResolvingPds] = useState(false); 42 + const [pdsResolutionError, setPdsResolutionError] = useState< 43 + string | undefined 44 + >(); 45 + 46 + const handleInputRef = useRef<TextInput>(null); 20 47 21 48 const { getLoginUrl, oauthCallback } = useStore((state) => state); 22 49 50 + const messageAnimation = useSharedValue(0); 51 + 52 + // focus on load 53 + useEffect(() => { 54 + if (handleInputRef.current) { 55 + handleInputRef.current.focus(); 56 + } 57 + }, []); 58 + 59 + useEffect(() => { 60 + if (isResolvingPds || pdsResolutionError || pdsUrl) { 61 + messageAnimation.value = withTiming(1, { duration: 500 }); 62 + } else { 63 + messageAnimation.value = withTiming(0, { duration: 400 }); 64 + } 65 + }, [isResolvingPds, pdsResolutionError, messageAnimation, pdsUrl]); 66 + 67 + const messageContainerAnimatedStyle = useAnimatedStyle(() => { 68 + return { 69 + opacity: messageAnimation.value, 70 + maxHeight: interpolate(messageAnimation.value, [0, 1], [0, 100]), 71 + marginTop: -8, 72 + paddingTop: 8, 73 + overflow: "hidden", 74 + zIndex: -1, 75 + }; 76 + }); 77 + 78 + const getPdsUrl = useCallback( 79 + async ( 80 + currentHandle: string, 81 + callbacks?: { 82 + onSuccess?: (resolvedPdsUrl: Url) => void; 83 + onError?: (errorMessage: string) => void; 84 + }, 85 + ): Promise<void> => { 86 + // Ensure we're not calling with an empty or whitespace-only handle due to debounce race. 87 + if (!currentHandle || currentHandle.trim() === "") { 88 + // Clear any potential resolving/error states if triggered by empty text 89 + setPdsResolutionError(undefined); 90 + setIsResolvingPds(false); 91 + setPdsUrl(null); 92 + callbacks?.onError?.("Handle cannot be empty for PDS resolution."); // Optional: notify caller 93 + return; 94 + } 95 + 96 + setIsResolvingPds(true); 97 + setPdsResolutionError(undefined); 98 + setPdsUrl(null); 99 + try { 100 + console.log(`Attempting to resolve PDS for handle: ${currentHandle}`); 101 + const identity: ResolvedIdentity | null = 102 + await resolveFromIdentity(currentHandle); 103 + 104 + if (!identity || !identity.pds) { 105 + throw new Error("Could not resolve PDS from the provided handle."); 106 + } 107 + 108 + setPdsUrl(identity.pds); 109 + callbacks?.onSuccess?.(identity.pds); 110 + setIsResolvingPds(false); 111 + } catch (e: any) { 112 + const errorMessage = 113 + e.message || "An unknown error occurred while resolving PDS."; 114 + setPdsResolutionError(errorMessage); 115 + callbacks?.onError?.(errorMessage); 116 + } finally { 117 + if (pdsResolutionError && isResolvingPds) { 118 + setIsResolvingPds(false); 119 + } 120 + } 121 + }, 122 + [isResolvingPds, pdsResolutionError], 123 + ); 124 + const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null); 125 + 126 + useEffect(() => { 127 + return () => { 128 + if (debounceTimeoutRef.current) { 129 + clearTimeout(debounceTimeoutRef.current); 130 + } 131 + }; 132 + }, []); 133 + 134 + const handleTextChange = useCallback( 135 + (text: string) => { 136 + setHandle(text); 137 + 138 + if (debounceTimeoutRef.current) { 139 + clearTimeout(debounceTimeoutRef.current); 140 + } 141 + 142 + if (text.trim().length > 3) { 143 + debounceTimeoutRef.current = setTimeout(() => { 144 + getPdsUrl(text.trim(), { 145 + onSuccess: (u) => { 146 + setPdsUrl(u); 147 + }, 148 + onError: (e) => { 149 + console.error(e); 150 + setPdsResolutionError("Couldn't resolve handle"); 151 + }, 152 + }); 153 + }, DEBOUNCE_DELAY); 154 + } else { 155 + setPdsResolutionError(undefined); 156 + setIsResolvingPds(false); 157 + setPdsUrl(null); 158 + } 159 + }, 160 + [getPdsUrl], 161 + ); 162 + 23 163 const handleLogin = async () => { 164 + // reset state 165 + if (debounceTimeoutRef.current) { 166 + clearTimeout(debounceTimeoutRef.current); 167 + } 168 + setIsResolvingPds(false); 169 + setPdsResolutionError(undefined); 170 + 24 171 if (!handle) { 25 172 setErr("Please enter a handle"); 26 173 return; 27 174 } 28 175 29 176 setIsLoading(true); 177 + setErr(undefined); 30 178 31 179 try { 32 180 let redirUrl = await getLoginUrl(handle.replace("@", "")); 33 181 if (!redirUrl) { 34 - // TODO: better error handling lulw 35 - throw new Error("Could not get login url. "); 182 + throw new Error("Could not get login url."); 36 183 } 37 184 setIsRedirecting(true); 38 185 if (Platform.OS === "web") { 39 - // redirect to redir url page without authsession 40 - // shyould! redirect to /auth/callback 41 186 router.navigate(redirUrl.toString()); 42 187 } else { 43 188 const res = await openAuthSessionAsync( ··· 47 192 if (res.type === "success") { 48 193 const params = new URLSearchParams(res.url.split("?")[1]); 49 194 await oauthCallback(params); 195 + } else if (res.type === "cancel" || res.type === "dismiss") { 196 + setErr("Login cancelled by user."); 197 + setIsRedirecting(false); 198 + setIsLoading(false); 199 + return; 200 + } else { 201 + throw new Error("Authentication failed or was cancelled."); 50 202 } 51 203 } 52 204 } catch (e: any) { 53 - console.error(e); 54 - setErr(e.message); 205 + setErr(e.message || "An unknown error occurred during login."); 55 206 setIsLoading(false); 56 207 setIsRedirecting(false); 57 208 return; ··· 67 218 headerShown: false, 68 219 }} 69 220 /> 70 - <View className="justify-center align-center p-8 gap-4 pb-32 max-w-screen-sm w-screen"> 221 + <View className="justify-center align-center p-8 gap-4 pb-32 max-w-lg w-screen"> 71 222 <View className="flex items-center"> 72 223 <Icon icon={AtSign} className="color-bsky" name="at" size={64} /> 73 224 </View> ··· 77 228 <View> 78 229 <Text className="text-sm text-muted-foreground">Handle</Text> 79 230 <Input 80 - className={err && `border-red-500 border-2`} 81 - placeholder="alice.bsky.social" 231 + ref={handleInputRef} 232 + className={cn( 233 + "ring-0", 234 + (err || pdsResolutionError) && `border-red-500`, 235 + )} 236 + placeholder="alice.bsky.social or did:plc:..." 82 237 value={handle} 83 - onChangeText={setHandle} 238 + onChangeText={handleTextChange} 239 + onFocus={(e) => setIsSelected(true)} 240 + onBlur={(e) => setIsSelected(false)} 84 241 autoCapitalize="none" 85 242 autoCorrect={false} 86 243 onKeyPress={(e) => { ··· 89 246 } 90 247 }} 91 248 /> 92 - {err ? ( 93 - <Text className="text-red-500 justify-baseline mt-1 text-xs"> 94 - <Icon 95 - icon={AlertCircle} 96 - className="mr-1 inline -mt-0.5 text-xs" 97 - size={20} 98 - /> 99 - {err} 100 - </Text> 101 - ) : ( 102 - <View className="h-6" /> 103 - )} 249 + 250 + <Animated.View style={messageContainerAnimatedStyle}> 251 + <View 252 + className={cn( 253 + "p-2 -mt-7 rounded-xl border border-border transition-all duration-300", 254 + isSelected ? "pt-9" : "pt-8", 255 + pdsUrl !== null 256 + ? pdsUrl.hostname.includes("bsky.network") 257 + ? "bg-sky-400 dark:bg-sky-800" 258 + : "bg-teal-400 dark:bg-teal-800" 259 + : pdsResolutionError && "bg-red-300 dark:bg-red-800", 260 + )} 261 + > 262 + {pdsUrl !== null ? ( 263 + <Text> 264 + PDS:{" "} 265 + {pdsUrl.hostname.includes("bsky.network") && ( 266 + <View className="gap-0.5 pr-0.5 flex-row"> 267 + <Icon 268 + icon={FontAwesome6} 269 + className="color-bsky" 270 + name="bluesky" 271 + size={16} 272 + /> 273 + <Icon 274 + icon={MaterialCommunityIcons} 275 + className="color-red-400" 276 + name="mushroom" 277 + size={18} 278 + /> 279 + </View> 280 + )} 281 + {pdsUrl.hostname.includes("bsky.network") 282 + ? capFirstLetter(pdsUrl.hostname.split(".").shift() || "") 283 + : pdsUrl.hostname} 284 + </Text> 285 + ) : pdsResolutionError ? ( 286 + <Text className="justify-baseline px-1"> 287 + <Icon 288 + icon={AlertCircle} 289 + className="mr-1 inline -mt-0.5 text-xs" 290 + size={24} 291 + /> 292 + {pdsResolutionError} 293 + </Text> 294 + ) : ( 295 + <Text className="text-muted-foreground px-1"> 296 + Resolving PDS... 297 + </Text> 298 + )} 299 + </View> 300 + </Animated.View> 104 301 </View> 105 302 <View className="flex flex-row justify-between items-center"> 106 - <Link href="https://bsky.app/signup"> 107 - <Text className="text-md ml-2 text-secondary"> 108 - Sign up for Bluesky 109 - </Text> 303 + <Link href="https://bsky.app/signup" asChild> 304 + <Button variant="link" className="p-0"> 305 + <Text className="text-md text-secondary"> 306 + Sign up for Bluesky 307 + </Text> 308 + </Button> 110 309 </Link> 111 310 <Button 112 311 className={cn( ··· 114 313 isRedirecting ? "bg-green-500" : "bg-bsky", 115 314 )} 116 315 onPress={handleLogin} 117 - disabled={isLoading} 316 + disabled={!pdsUrl} 118 317 > 119 318 {isRedirecting ? ( 120 319 <> 121 320 <Text className="text-lg">Redirecting</Text> 122 321 <Icon icon={Check} /> 123 322 </> 323 + ) : isLoading ? ( 324 + <Text className="text-lg">Signing in...</Text> 124 325 ) : ( 125 326 <> 126 327 <Text className="text-lg">Sign in</Text>
+41 -35
apps/amethyst/lib/atp/pds.ts
··· 14 14 * @returns The PDS endpoint, if available 15 15 */ 16 16 export const getPdsEndpoint = (doc: DidDocument): string | undefined => { 17 - return getServiceEndpoint(doc, '#atproto_pds', 'AtprotoPersonalDataServer'); 17 + return getServiceEndpoint(doc, "#atproto_pds", "AtprotoPersonalDataServer"); 18 18 }; 19 19 20 20 /** ··· 25 25 * @returns The requested service endpoint, if available 26 26 */ 27 27 export const getServiceEndpoint = ( 28 - doc: DidDocument, 29 - serviceId: string, 30 - serviceType: string, 28 + doc: DidDocument, 29 + serviceId: string, 30 + serviceType: string, 31 31 ): string | undefined => { 32 - const did = doc.id; 32 + const did = doc.id; 33 33 34 - const didServiceId = did + serviceId; 35 - const found = doc.service?.find((service) => service.id === serviceId || service.id === didServiceId); 34 + const didServiceId = did + serviceId; 35 + const found = doc.service?.find( 36 + (service) => service.id === serviceId || service.id === didServiceId, 37 + ); 36 38 37 - if (!found || found.type !== serviceType || typeof found.serviceEndpoint !== 'string') { 38 - return undefined; 39 - } 39 + if ( 40 + !found || 41 + found.type !== serviceType || 42 + typeof found.serviceEndpoint !== "string" 43 + ) { 44 + return undefined; 45 + } 40 46 41 - return validateUrl(found.serviceEndpoint); 47 + return validateUrl(found.serviceEndpoint); 42 48 }; 43 49 const validateUrl = (urlStr: string): string | undefined => { 44 - let url; 45 - try { 46 - url = new URL(urlStr); 47 - } catch { 48 - return undefined; 49 - } 50 + let url; 51 + try { 52 + url = new URL(urlStr); 53 + } catch { 54 + return undefined; 55 + } 50 56 51 - const proto = url.protocol; 57 + const proto = url.protocol; 52 58 53 - if (url.hostname && (proto === 'http:' || proto === 'https:')) { 54 - return urlStr; 55 - } 59 + if (url.hostname && (proto === "http:" || proto === "https:")) { 60 + return urlStr; 61 + } 56 62 }; 57 63 58 64 /** 59 65 * DID document 60 66 */ 61 67 export interface DidDocument { 62 - id: string; 63 - alsoKnownAs?: string[]; 64 - verificationMethod?: Array<{ 65 - id: string; 66 - type: string; 67 - controller: string; 68 - publicKeyMultibase?: string; 69 - }>; 70 - service?: Array<{ 71 - id: string; 72 - type: string; 73 - serviceEndpoint: string | Record<string, unknown>; 74 - }>; 75 - } 68 + id: string; 69 + alsoKnownAs?: string[]; 70 + verificationMethod?: { 71 + id: string; 72 + type: string; 73 + controller: string; 74 + publicKeyMultibase?: string; 75 + }[]; 76 + service?: { 77 + id: string; 78 + type: string; 79 + serviceEndpoint: string | Record<string, unknown>; 80 + }[]; 81 + }
+6
apps/amethyst/lib/utils.ts
··· 4 4 export function cn(...inputs: ClassValue[]) { 5 5 return twMerge(clsx(inputs)); 6 6 } 7 + 8 + export function capFirstLetter(str: string) { 9 + let arr = str.split(""); 10 + let first = arr.shift()?.toUpperCase(); 11 + return (first || "") + arr.join(""); 12 + }