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

Add 'stamp' feature to amethyst

authored by

Natalie and committed by
Natalie B.
71ef20ba eaa59d40

+661 -350
+23 -11
apps/amethyst/app/(tabs)/_layout.tsx
··· 1 1 import React from "react"; 2 - import { CodeXml, Home, Info, type LucideIcon } from "lucide-react-native"; 3 - import { Link, Stack, Tabs } from "expo-router"; 2 + import { 3 + FilePen, 4 + Home, 5 + Info, 6 + LogOut, 7 + type LucideIcon, 8 + } from "lucide-react-native"; 9 + import { Link, Tabs } from "expo-router"; 4 10 import { Pressable } from "react-native"; 5 11 6 12 import Colors from "../../constants/Colors"; 7 13 import { useColorScheme } from "../../components/useColorScheme"; 8 14 import { useClientOnlyValue } from "../../components/useClientOnlyValue"; 9 - import { iconWithClassName } from "../../lib/icons/iconWithClassName"; 15 + import { Icon, iconWithClassName } from "../../lib/icons/iconWithClassName"; 16 + import useIsMobile from "@/hooks/useIsMobile"; 17 + import { useStore } from "@/stores/mainStore"; 10 18 11 19 function TabBarIcon(props: { name: LucideIcon; color: string }) { 12 20 const Name = props.name; 13 21 iconWithClassName(Name); 14 - return <Name size={28} className="mt-4" {...props} />; 22 + return <Name size={28} className="" {...props} />; 15 23 } 16 24 17 25 export default function TabLayout() { 18 26 const colorScheme = useColorScheme(); 27 + const authStatus = useStore((state) => state.status); 28 + // if we are on web but not native and web width is greater than 1024px 29 + const hideTabBar = useIsMobile() || authStatus !== "loggedIn"; 19 30 20 31 return ( 21 32 <Tabs ··· 28 39 tabBarShowLabel: false, 29 40 tabBarStyle: { 30 41 height: 75, 42 + display: hideTabBar ? "none" : "flex", 31 43 }, 32 44 }} 33 45 > ··· 37 49 title: "Tab One", 38 50 tabBarIcon: ({ color }) => <TabBarIcon name={Home} color={color} />, 39 51 headerRight: () => ( 40 - <Link href="/modal" asChild> 52 + <Link href="/auth/logoutModal" asChild> 41 53 <Pressable> 42 54 {({ pressed }) => ( 43 - <Info 44 - size={25} 45 - color={Colors[colorScheme ?? "light"].text} 46 - style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }} 55 + <Icon 56 + icon={LogOut} 57 + className="text-2xl mr-4" 58 + name="log-out" 47 59 /> 48 60 )} 49 61 </Pressable> ··· 52 64 }} 53 65 /> 54 66 <Tabs.Screen 55 - name="two" 67 + name="button" 56 68 options={{ 57 69 title: "Tab Two", 58 70 tabBarIcon: ({ color }) => ( 59 - <TabBarIcon name={CodeXml} color={color} /> 71 + <TabBarIcon name={FilePen} color={color} /> 60 72 ), 61 73 }} 62 74 />
+20 -9
apps/amethyst/app/(tabs)/index.tsx
··· 16 16 import AuthOptions from "../auth/options"; 17 17 18 18 import { Response } from "@atproto/api/src/client/types/app/bsky/actor/getProfile"; 19 + import { Link, Stack } from "expo-router"; 20 + import { Button } from "@/components/ui/button"; 19 21 20 22 const GITHUB_AVATAR_URI = 21 23 "https://i.pinimg.com/originals/ef/a2/8d/efa28d18a04e7fa40ed49eeb0ab660db.jpg"; 22 24 23 25 export default function Screen() { 24 - const [progress, setProgress] = React.useState(78); 25 26 const [profile, setProfile] = React.useState<Response | null>(null); 26 27 const j = useStore((state) => state.status); 27 28 // @me ··· 41 42 } else { 42 43 console.log("No agent"); 43 44 } 44 - }, [isReady]); 45 + }, [isReady, agent]); 45 46 46 47 if (j !== "loggedIn") { 47 - //router.replace("/auth/options"); 48 48 return <AuthOptions />; 49 49 } 50 50 51 - function updateProgressValue() { 52 - setProgress(Math.floor(Math.random() * 100)); 53 - } 54 51 return ( 55 52 <View className="flex-1 justify-center items-center gap-5 p-6 bg-background"> 56 - <Card className="w-full max-w-full p-6 rounded-2xl"> 57 - <CardHeader className="items-center"> 53 + <Stack.Screen 54 + options={{ 55 + title: "Home", 56 + headerBackButtonDisplayMode: "minimal", 57 + headerShown: false, 58 + }} 59 + /> 60 + <Card className="py-6 rounded-2xl border-2 border-foreground"> 61 + <CardHeader className="items-center pb-0"> 58 62 <Avatar alt="Rick Sanchez's Avatar" className="w-24 h-24"> 59 63 <AvatarImage 60 64 source={{ uri: profile?.data.avatar ?? GITHUB_AVATAR_URI }} ··· 65 69 </Text> 66 70 </AvatarFallback> 67 71 </Avatar> 68 - <View className="p-3" /> 72 + <View className="px-3" /> 69 73 <CardTitle className="text-center"> 70 74 {profile?.data.displayName ?? " Richard"} 71 75 </CardTitle> ··· 79 83 : "Loading..."} 80 84 </CardContent> 81 85 </CardHeader> 86 + <CardContent className="flex flex-row justify-center items-center p-0"> 87 + <Link href="/stamp"> 88 + <Button> 89 + <Text className="text-center">Ready to stamp!</Text>{" "} 90 + </Button> 91 + </Link> 92 + </CardContent> 82 93 </Card> 83 94 </View> 84 95 );
+370
apps/amethyst/app/(tabs)/stamp.tsx
··· 1 + import { 2 + View, 3 + TextInput, 4 + ScrollView, 5 + TouchableOpacity, 6 + FlatList, 7 + Image, 8 + Alert, 9 + Modal, 10 + } from "react-native"; 11 + import { useState } from "react"; 12 + import { useStore } from "../../stores/mainStore"; 13 + import { Button } from "../../components/ui/button"; 14 + import { Text } from "../../components/ui/text"; 15 + import { validateRecord } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play"; 16 + import { Icon } from "@/lib/icons/iconWithClassName"; 17 + import { Brain, Check } from "lucide-react-native"; 18 + import { Link, Stack } from "expo-router"; 19 + import React from "react"; 20 + 21 + async function searchMusicbrainz(searchParams: { 22 + track?: string; 23 + artist?: string; 24 + }) { 25 + try { 26 + const queryParts = []; 27 + if (searchParams.track) 28 + queryParts.push(`release title:"${searchParams.track}"`); 29 + if (searchParams.artist) 30 + queryParts.push(`AND artist:"${searchParams.artist}"`); 31 + 32 + const query = queryParts.join(" AND "); 33 + 34 + const res = await fetch( 35 + `https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent( 36 + query, 37 + )}&fmt=json`, 38 + ); 39 + const data = await res.json(); 40 + return data.recordings || []; 41 + } catch (error) { 42 + console.error("Failed to fetch MusicBrainz data:", error); 43 + return []; 44 + } 45 + } 46 + 47 + export default function TabTwoScreen() { 48 + const agent = useStore((state) => state.pdsAgent); 49 + const [searchFields, setSearchFields] = useState({ 50 + track: "", 51 + artist: "", 52 + release: "", 53 + }); 54 + const [searchResults, setSearchResults] = useState([]); 55 + const [selectedTrack, setSelectedTrack] = useState(null); 56 + const [selectedRelease, setSelectedRelease] = useState(null); 57 + const [isLoading, setIsLoading] = useState(false); 58 + const [isSubmitting, setIsSubmitting] = useState(false); 59 + 60 + const [releaseSelections, setReleaseSelections] = useState({}); 61 + 62 + const handleTrackSelect = (track) => { 63 + setSelectedTrack(track); 64 + // Reset selected release when track is deselected 65 + if (!track) { 66 + setSelectedRelease(null); 67 + } 68 + }; 69 + 70 + const handleSearch = async () => { 71 + if (!searchFields.track && !searchFields.artist && !searchFields.release) { 72 + return; 73 + } 74 + 75 + setIsLoading(true); 76 + setSelectedTrack(null); 77 + const results = await searchMusicbrainz(searchFields); 78 + setSearchResults(results); 79 + setIsLoading(false); 80 + }; 81 + 82 + const createPlayRecord = (result) => { 83 + return { 84 + trackName: result.title ?? "Unknown Title", 85 + recordingMbId: result.id ?? undefined, 86 + duration: result.length ? Math.floor(result.length / 1000) : undefined, 87 + artistName: 88 + result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist", 89 + artistMbIds: result["artist-credit"]?.[0]?.artist?.id 90 + ? [result["artist-credit"][0].artist.id] 91 + : undefined, 92 + releaseName: result.selectedRelease?.title ?? undefined, 93 + releaseMbId: result.selectedRelease?.id ?? undefined, 94 + isrc: result.isrcs?.[0] ?? undefined, 95 + originUrl: `https://tidal.com/browse/track/274816578?u`, 96 + musicServiceBaseDomain: "tidal.com", 97 + submissionClientAgent: "tealtracker/0.0.1b", 98 + playedTime: new Date().toISOString(), 99 + }; 100 + }; 101 + 102 + const submitPlay = async () => { 103 + if (!selectedTrack) return; 104 + 105 + setIsSubmitting(true); 106 + const play = createPlayRecord(selectedTrack); 107 + 108 + try { 109 + let result = validateRecord(play); 110 + console.log("Validated play:", result); 111 + const res = await agent?.call( 112 + "com.atproto.repo.createRecord", 113 + {}, 114 + { 115 + repo: agent.did, 116 + collection: "fm.teal.alpha.feed.play", 117 + rkey: undefined, 118 + record: play, 119 + }, 120 + ); 121 + console.log("Play submitted successfully:", res); 122 + // Reset after successful submission 123 + setSelectedTrack(null); 124 + setSearchResults([]); 125 + setSearchFields({ track: "", artist: "", release: "" }); 126 + } catch (error) { 127 + console.error("Failed to submit play:", error); 128 + } finally { 129 + setIsSubmitting(false); 130 + } 131 + }; 132 + 133 + const clearSearch = () => { 134 + setSearchFields({ track: "", artist: "", release: "" }); 135 + setSearchResults([]); 136 + setSelectedTrack(null); 137 + }; 138 + 139 + const SearchResult = ({ 140 + result, 141 + onSelectTrack, 142 + isSelected, 143 + selectedRelease, 144 + onReleaseSelect, 145 + }) => { 146 + const [showReleaseModal, setShowReleaseModal] = useState(false); 147 + 148 + const currentRelease = selectedRelease || result.releases?.[0]; 149 + 150 + return ( 151 + <TouchableOpacity 152 + onPress={() => { 153 + onSelectTrack( 154 + isSelected 155 + ? null 156 + : { 157 + ...result, 158 + selectedRelease: currentRelease, // Pass the selected release with the track 159 + }, 160 + ); 161 + }} 162 + className={`p-4 mb-2 rounded-lg ${ 163 + isSelected ? "bg-primary/20" : "bg-secondary/10" 164 + }`} 165 + > 166 + <View className="flex-row justify-between items-center gap-2"> 167 + <Image 168 + className="w-16 h-16 rounded-lg bg-gray-500/50" 169 + source={{ 170 + uri: `https://coverartarchive.org/release/${currentRelease?.id}/front-250`, 171 + }} 172 + /> 173 + <View className="flex-1"> 174 + <Text className="font-bold">{result.title}</Text> 175 + <Text className="text-sm text-gray-600"> 176 + {result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist"} 177 + </Text> 178 + 179 + {/* Release Selector Button */} 180 + {result.releases?.length > 0 && ( 181 + <TouchableOpacity 182 + onPress={() => setShowReleaseModal(true)} 183 + className="p-1 bg-secondary/10 rounded-lg flex md:flex-row items-start md:gap-1" 184 + > 185 + <Text className="text-sm text-gray-500">Release:</Text> 186 + <Text className="text-sm" numberOfLines={1}> 187 + {currentRelease?.title} 188 + {currentRelease?.date ? ` (${currentRelease.date})` : ""} 189 + {currentRelease?.country 190 + ? ` - ${currentRelease.country}` 191 + : ""} 192 + </Text> 193 + </TouchableOpacity> 194 + )} 195 + </View> 196 + {/* Existing icons */} 197 + <Link href={`https://musicbrainz.org/recording/${result.id}`}> 198 + <View className="bg-primary/40 rounded-full p-1"> 199 + <Icon icon={Brain} size={20} /> 200 + </View> 201 + </Link> 202 + {isSelected ? ( 203 + <View className="bg-primary rounded-full p-1"> 204 + <Icon icon={Check} size={20} /> 205 + </View> 206 + ) : ( 207 + <View className="border-2 border-secondary rounded-full p-3"></View> 208 + )} 209 + </View> 210 + 211 + {/* Release Selection Modal */} 212 + <Modal 213 + visible={showReleaseModal} 214 + transparent={true} 215 + animationType="slide" 216 + onRequestClose={() => setShowReleaseModal(false)} 217 + > 218 + <View className="flex-1 justify-end bg-black/50"> 219 + <View className="bg-background rounded-t-3xl"> 220 + <View className="p-4 border-b border-gray-200"> 221 + <Text className="text-lg font-bold text-center"> 222 + Select Release 223 + </Text> 224 + <TouchableOpacity 225 + className="absolute right-4 top-4" 226 + onPress={() => setShowReleaseModal(false)} 227 + > 228 + <Text className="text-primary">Done</Text> 229 + </TouchableOpacity> 230 + </View> 231 + 232 + <ScrollView className="max-h-[50vh]"> 233 + {result.releases?.map((release) => ( 234 + <TouchableOpacity 235 + key={release.id} 236 + className={`p-4 border-b border-gray-100 ${ 237 + selectedRelease?.id === release.id ? "bg-primary/10" : "" 238 + }`} 239 + onPress={() => { 240 + onReleaseSelect(result.id, release); 241 + setShowReleaseModal(false); 242 + }} 243 + > 244 + <Text className="font-medium">{release.title}</Text> 245 + <View className="flex-row gap-2"> 246 + {release.date && ( 247 + <Text className="text-sm text-gray-500"> 248 + {release.date} 249 + </Text> 250 + )} 251 + {release.country && ( 252 + <Text className="text-sm text-gray-500"> 253 + {release.country} 254 + </Text> 255 + )} 256 + {release.status && ( 257 + <Text className="text-sm text-gray-500"> 258 + {release.status} 259 + </Text> 260 + )} 261 + </View> 262 + {release.disambiguation && ( 263 + <Text className="text-sm text-gray-400 italic"> 264 + {release.disambiguation} 265 + </Text> 266 + )} 267 + </TouchableOpacity> 268 + ))} 269 + </ScrollView> 270 + </View> 271 + </View> 272 + </Modal> 273 + </TouchableOpacity> 274 + ); 275 + }; 276 + return ( 277 + <ScrollView className="flex-1 p-4 bg-background items-center"> 278 + <Stack.Screen 279 + options={{ 280 + title: "Home", 281 + headerBackButtonDisplayMode: "minimal", 282 + headerShown: false, 283 + }} 284 + /> 285 + {/* Search Form */} 286 + <View className="flex gap-4 max-w-screen-md w-screen px-4"> 287 + <Text className="font-bold text-lg">Search for a track</Text> 288 + <TextInput 289 + className="p-2 border rounded-lg border-gray-300 bg-white" 290 + placeholder="Track name..." 291 + value={searchFields.track} 292 + onChangeText={(text) => 293 + setSearchFields((prev) => ({ ...prev, track: text })) 294 + } 295 + /> 296 + <TextInput 297 + className="p-2 border rounded-lg border-gray-300 bg-white" 298 + placeholder="Artist name..." 299 + value={searchFields.artist} 300 + onChangeText={(text) => 301 + setSearchFields((prev) => ({ ...prev, artist: text })) 302 + } 303 + /> 304 + <View className="flex-row gap-2"> 305 + <Button 306 + className="flex-1" 307 + onPress={handleSearch} 308 + disabled={ 309 + isLoading || 310 + (!searchFields.track && 311 + !searchFields.artist && 312 + !searchFields.release) 313 + } 314 + > 315 + <Text>{isLoading ? "Searching..." : "Search"}</Text> 316 + </Button> 317 + <Button className="flex-1" onPress={clearSearch} variant="outline"> 318 + <Text>Clear</Text> 319 + </Button> 320 + </View> 321 + </View> 322 + 323 + {/* Search Results */} 324 + <View className="flex gap-4 max-w-screen-md w-screen px-4"> 325 + {searchResults.length > 0 && ( 326 + <View className="mt-4"> 327 + <Text className="text-lg font-bold mb-2"> 328 + Search Results ({searchResults.length}) 329 + </Text> 330 + <FlatList 331 + data={searchResults} 332 + renderItem={({ item }) => ( 333 + <SearchResult 334 + result={item} 335 + onSelectTrack={handleTrackSelect} 336 + isSelected={selectedTrack?.id === item.id} 337 + selectedRelease={releaseSelections[item.id]} 338 + onReleaseSelect={(trackId, release) => { 339 + setReleaseSelections((prev) => ({ 340 + ...prev, 341 + [trackId]: release, 342 + })); 343 + }} 344 + /> 345 + )} 346 + keyExtractor={(item) => item.id} 347 + /> 348 + </View> 349 + )} 350 + 351 + {/* Submit Button */} 352 + {selectedTrack && ( 353 + <View className="mt-4 sticky bottom-0"> 354 + <Button 355 + onPress={submitPlay} 356 + disabled={isSubmitting} 357 + className="w-full" 358 + > 359 + <Text> 360 + {isSubmitting 361 + ? "Submitting..." 362 + : `Submit "${selectedTrack.title}" as Play`} 363 + </Text> 364 + </Button> 365 + </View> 366 + )} 367 + </View> 368 + </ScrollView> 369 + ); 370 + }
-86
apps/amethyst/app/(tabs)/two.tsx
··· 1 - import { View } from "react-native"; 2 - 3 - import { useStore } from "../../stores/mainStore"; 4 - import { Button } from "../../components/ui/button"; 5 - import { Text } from "../../components/ui/text"; 6 - 7 - import { 8 - Record as Play, 9 - validateRecord, 10 - } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play"; 11 - 12 - async function searchMusicbrainz(query: string) { 13 - try { 14 - const res = await fetch( 15 - `https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent(query)}&fmt=json`, 16 - ); 17 - const data = await res.json(); 18 - return data.recordings?.[0]; // Get the first recording result 19 - } catch (error) { 20 - console.error("Failed to fetch MusicBrainz data:", error); 21 - return null; 22 - } 23 - } 24 - 25 - export default function TabTwoScreen() { 26 - const agent = useStore((state) => state.pdsAgent); 27 - 28 - const submitPlay = async () => { 29 - const query = "release title:this is why AND artist:Paramore"; 30 - const result = await searchMusicbrainz(query); 31 - 32 - if (result) { 33 - console.log(result); 34 - const play: Play = { 35 - trackName: result.title ?? "Unknown Title", 36 - recordingMbId: result.id ?? undefined, 37 - duration: result.length ? Math.floor(result.length / 1000) : undefined, // Convert ms to seconds 38 - artistName: 39 - result["artist-credit"]?.[0]?.artist?.name ?? "Unknown Artist", 40 - artistMbIds: result["artist-credit"]?.[0]?.artist?.id 41 - ? [result["artist-credit"][0].artist.id] 42 - : undefined, 43 - releaseName: result["releases"]?.[0]?.title ?? undefined, 44 - releaseMbId: result["releases"]?.[0]?.id ?? undefined, 45 - isrc: result.isrcs?.[0] ?? undefined, 46 - originUrl: `https://tidal.com/browse/track/274816578?u`, 47 - musicServiceBaseDomain: "tidal.com", 48 - submissionClientAgent: "tealtracker/0.0.1b", 49 - playedTime: new Date().toISOString(), 50 - }; 51 - 52 - try { 53 - let result = validateRecord(play); 54 - console.log("Validated play:", result); 55 - console.log("Submitting play:", play); 56 - // const res = await agent?.call( 57 - // "com.atproto.repo.createRecord", 58 - // {}, 59 - // { 60 - // repo: agent.did, 61 - // collection: "fm.teal.alpha.play", 62 - // rkey: undefined, 63 - // record: play, 64 - // } 65 - // ); 66 - // console.log("Play submitted successfully:", res); 67 - } catch (error) { 68 - console.error("Failed to submit play:", error); 69 - } 70 - } else { 71 - console.error("No results found for the query."); 72 - } 73 - }; 74 - 75 - return ( 76 - <View className="flex-1 flex gap-2 items-center justify-center align-center w-full h-full bg-background"> 77 - {agent ? ( 78 - <Button onPress={() => submitPlay()}> 79 - <Text>Get Profile</Text> 80 - </Button> 81 - ) : ( 82 - <Text>Loading...</Text> 83 - )} 84 - </View> 85 - ); 86 - }
+8 -1
apps/amethyst/app/_layout.tsx
··· 91 91 <GlobalTextClassContext.Provider value="font-sans"> 92 92 <Stack> 93 93 <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> 94 - <Stack.Screen name="modal" options={{ presentation: "modal" }} /> 94 + <Stack.Screen 95 + name="auth/logoutModal" 96 + options={{ 97 + presentation: "transparentModal", 98 + animation: "fade", 99 + headerShown: false, 100 + }} 101 + /> 95 102 </Stack> 96 103 <PortalHost /> 97 104 </GlobalTextClassContext.Provider>
+16 -8
apps/amethyst/app/auth/callback.tsx
··· 16 16 export default function AuthOptions() { 17 17 const { oauthCallback, status } = useStore((state) => state); 18 18 19 - const params = useLocalSearchParams<'iss' | 'state' | 'code'>(); 20 - const {state} = params; 19 + const params = useLocalSearchParams<"iss" | "state" | "code">(); 20 + const { state } = params; 21 21 useEffect(() => { 22 - // exchange the tokens for jwt 22 + // Only proceed if params exist 23 + if (params) { 23 24 const searchParams = new URLSearchParams(params); 24 25 oauthCallback(searchParams); 25 - }, []); 26 + } 27 + }, [params, oauthCallback]); 26 28 27 29 useEffect(() => { 30 + // Wrap navigation in requestAnimationFrame to ensure root layout is mounted 28 31 if (status === "loggedIn") { 29 - router.replace("/"); 32 + requestAnimationFrame(() => { 33 + router.replace("/"); 34 + }); 30 35 } 31 - }, [state]) 36 + }, [status]); 37 + 32 38 // if no state then redirect to error page 33 39 if (!params) { 34 40 return ( ··· 50 56 {status === "loggedIn" ? "Success!" : "Fetching your data..."} 51 57 </Text> 52 58 <Text className="text-sm text-muted-foreground"> 53 - This may take a few seconds {status} 59 + This may take a few seconds {status} 54 60 </Text> 55 - <Text className="text-sm text-muted-foreground font-mono bg-muted-foreground/30 py-1 px-2 rounded-full">{state}</Text> 61 + <Text className="text-sm text-muted-foreground font-mono bg-muted-foreground/30 py-1 px-2 rounded-full"> 62 + {state} 63 + </Text> 56 64 </View> 57 65 ); 58 66 }
+132
apps/amethyst/app/auth/login.tsx
··· 1 + import React, { useState } from "react"; 2 + import { View, Platform } from "react-native"; 3 + import { SafeAreaView } from "react-native-safe-area-context"; 4 + import { Text } from "@/components/ui/text"; 5 + import { Button } from "@/components/ui/button"; 6 + import { Icon } from "@/lib/icons/iconWithClassName"; 7 + import { Check, ChevronRight, AtSign, AlertCircle } from "lucide-react-native"; 8 + import { Input } from "@/components/ui/input"; 9 + import { cn } from "@/lib/utils"; 10 + import { Link, Stack, router } from "expo-router"; 11 + 12 + import { useStore } from "@/stores/mainStore"; 13 + import { openAuthSessionAsync } from "expo-web-browser"; 14 + 15 + const LoginScreen = () => { 16 + const [handle, setHandle] = useState(""); 17 + const [err, setErr] = useState<string | undefined>(); 18 + const [isRedirecting, setIsRedirecting] = useState(false); 19 + const [isLoading, setIsLoading] = useState(false); 20 + 21 + const { getLoginUrl, oauthCallback } = useStore((state) => state); 22 + 23 + const handleLogin = async () => { 24 + if (!handle) { 25 + setErr("Please enter a handle"); 26 + return; 27 + } 28 + 29 + setIsLoading(true); 30 + 31 + try { 32 + let redirUrl = await getLoginUrl(handle.replace("@", "")); 33 + if (!redirUrl) { 34 + // TODO: better error handling lulw 35 + throw new Error("Does not resolve to a DID"); 36 + } 37 + setIsRedirecting(true); 38 + if (Platform.OS === "web") { 39 + // redirect to redir url page without authsession 40 + // shyould! redirect to /auth/callback 41 + router.navigate(redirUrl.toString()); 42 + } else { 43 + const res = await openAuthSessionAsync( 44 + redirUrl.toString(), 45 + "http://127.0.0.1:8081/login", 46 + ); 47 + if (res.type === "success") { 48 + const params = new URLSearchParams(res.url.split("?")[1]); 49 + await oauthCallback(params); 50 + } 51 + } 52 + } catch (e: any) { 53 + console.error(e); 54 + setErr(e.message); 55 + setIsLoading(false); 56 + setIsRedirecting(false); 57 + return; 58 + } 59 + }; 60 + 61 + return ( 62 + <SafeAreaView className="flex-1 flex items-center justify-center w-full"> 63 + <Stack.Screen 64 + options={{ 65 + title: "Sign in", 66 + headerBackButtonDisplayMode: "minimal", 67 + headerShown: false, 68 + }} 69 + /> 70 + <View className="justify-center align-center p-8 gap-4 pb-32 max-w-screen-sm w-screen"> 71 + <View className="flex items-center"> 72 + <Icon icon={AtSign} className="color-bsky" name="at" size={64} /> 73 + </View> 74 + <Text className="text-3xl font-semibold text-center text-foreground"> 75 + Sign in with your PDS 76 + </Text> 77 + <View> 78 + <Text className="text-sm text-muted-foreground">Handle</Text> 79 + <Input 80 + className={err && `border-red-500 border-2`} 81 + placeholder="alice.bsky.social" 82 + value={handle} 83 + onChangeText={setHandle} 84 + autoCapitalize="none" 85 + autoCorrect={false} 86 + /> 87 + {err ? ( 88 + <Text className="text-red-500 justify-baseline mt-1 text-xs"> 89 + <Icon 90 + icon={AlertCircle} 91 + className="mr-1 inline -mt-0.5 text-xs" 92 + size={20} 93 + /> 94 + {err} 95 + </Text> 96 + ) : ( 97 + <View className="h-6" /> 98 + )} 99 + </View> 100 + <View className="flex flex-row justify-between items-center"> 101 + <Link href="https://bsky.app/signup"> 102 + <Text className="text-md ml-2 text-secondary"> 103 + Sign up for Bluesky 104 + </Text> 105 + </Link> 106 + <Button 107 + className={cn( 108 + "flex flex-row justify-end duration-500", 109 + isRedirecting ? "bg-green-500" : "bg-bsky", 110 + )} 111 + onPress={handleLogin} 112 + disabled={isLoading} 113 + > 114 + {isRedirecting ? ( 115 + <> 116 + <Text className="font-semibold text-lg">Redirecting</Text> 117 + <Icon icon={Check} /> 118 + </> 119 + ) : ( 120 + <> 121 + <Text className="font-semibold text-lg">Login</Text> 122 + <Icon icon={ChevronRight} /> 123 + </> 124 + )} 125 + </Button> 126 + </View> 127 + </View> 128 + </SafeAreaView> 129 + ); 130 + }; 131 + 132 + export default LoginScreen;
+43
apps/amethyst/app/auth/logoutModal.tsx
··· 1 + import { StatusBar } from "expo-status-bar"; 2 + import { Platform, StyleSheet, TouchableOpacity } from "react-native"; 3 + 4 + import { View } from "react-native"; 5 + import { Text } from "../../components/ui/text"; 6 + import { useStore } from "@/stores/mainStore"; 7 + import { Button } from "@/components/ui/button"; 8 + import { router } from "expo-router"; 9 + import { X } from "lucide-react-native"; 10 + import { Icon } from "@/lib/icons/iconWithClassName"; 11 + 12 + // should probably be a WebModal component or something? 13 + export default function ModalScreen() { 14 + // handle log out 15 + const { logOut } = useStore((state) => state); 16 + const handleGoBack = () => { 17 + router.back(); 18 + }; 19 + return ( 20 + <TouchableOpacity 21 + className="flex relative justify-center items-center bg-muted-foreground/60 w-screen h-screen backdrop-blur-sm" 22 + onPress={() => handleGoBack()} 23 + > 24 + <Icon icon={X} className="top-2 right-2 absolute" name="x" /> 25 + <View className="flex-1 items-center justify-center gap-2 bg-background w-full max-w-96 max-h-80 shadow-xl rounded-xl"> 26 + <Text className="text-4xl">Surprise!</Text> 27 + <Text className="text-xl">You can sign out here!</Text> 28 + <Button 29 + onPress={() => { 30 + logOut(); 31 + // redirect to home 32 + router.navigate("/"); 33 + }} 34 + > 35 + <Text className="font-semibold text-lg">Sign out</Text> 36 + </Button> 37 + 38 + {/* Use a light status bar on iOS to account for the black space above the modal */} 39 + <StatusBar style={Platform.OS === "ios" ? "light" : "auto"} /> 40 + </View> 41 + </TouchableOpacity> 42 + ); 43 + }
+3 -4
apps/amethyst/app/auth/options.tsx
··· 25 25 <Text className="text-5xl font-serif-old-italic">.fm</Text> 26 26 </Text> 27 27 </View> 28 - <Link href="/login" className="text-secondary"> 28 + <Link href="/auth/login" className="text-secondary"> 29 29 <Button 30 30 className="flex flex-row justify-center items-center rounded-full dark-blue-800 dark:bg-blue-400 gap-2" 31 31 size="lg" 32 32 onTouchStart={() => { 33 - router.push("/login"); 33 + router.push("/auth/login"); 34 34 }} 35 35 > 36 - 37 36 <Text>Sign in with ATProto</Text> 38 37 </Button> 39 38 </Link> ··· 42 41 className="flex flex-row justify-center items-center rounded-full" 43 42 size="lg" 44 43 onTouchStart={() => { 45 - router.push("/signup"); 44 + router.push("/auth/signup"); 46 45 }} 47 46 > 48 47 <Text>Sign up</Text>
-188
apps/amethyst/app/login.tsx
··· 1 - import React, { useEffect, useState } from "react"; 2 - import { 3 - View, 4 - TextInput, 5 - TouchableOpacity, 6 - Alert, 7 - Linking, 8 - Platform, 9 - } from "react-native"; 10 - import { SafeAreaView } from "react-native-safe-area-context"; 11 - import { Text } from "../components/ui/text"; 12 - import { Button } from "../components/ui/button"; 13 - import { Icon } from "../lib/icons/iconWithClassName"; 14 - import { Check, ChevronRight, AtSign, AlertCircle } from "lucide-react-native"; 15 - import { Input } from "../components/ui/input"; 16 - import { cn } from "../lib/utils"; 17 - import { Link, Stack, router } from "expo-router"; 18 - 19 - import { useStore } from "../stores/mainStore"; 20 - import createOAuthClient from "../lib/atp/oauth"; 21 - import { resolveFromIdentity } from "../lib/atp/pid"; 22 - import { openAuthSessionAsync } from "expo-web-browser"; 23 - 24 - const LoginScreen = () => { 25 - const [handle, setHandle] = useState(""); 26 - const [err, setErr] = useState<string | undefined>(); 27 - const [isRedirecting, setIsRedirecting] = useState(false); 28 - const [isLoading, setIsLoading] = useState(false); 29 - 30 - const { getLoginUrl, oauthCallback, status } = useStore((state) => state); 31 - 32 - const handleLogin = async () => { 33 - if (!handle) { 34 - setErr("Please enter a handle"); 35 - return; 36 - } 37 - 38 - setIsLoading(true); 39 - 40 - try { 41 - let redirUrl = await getLoginUrl(handle.replace("@", "")); 42 - if (!redirUrl) { 43 - // TODO: better error handling lulw 44 - throw new Error("Does not resolve to a DID"); 45 - } 46 - setIsRedirecting(true); 47 - if (Platform.OS === "web") { 48 - // redirect to redir url page without authsession 49 - // shyould! redirect to /auth/callback 50 - router.navigate(redirUrl.toString()); 51 - } else { 52 - const res = await openAuthSessionAsync( 53 - redirUrl.toString(), 54 - "http://127.0.0.1:8081/login" 55 - ); 56 - if (res.type === "success") { 57 - const params = new URLSearchParams(res.url.split("?")[1]); 58 - await oauthCallback(params); 59 - } 60 - } 61 - } catch (e: any) { 62 - console.error(e); 63 - setErr(e.message); 64 - setIsLoading(false); 65 - setIsRedirecting(false); 66 - return; 67 - } 68 - 69 - // try { 70 - // const response = await fetch( 71 - // "https://natshare.z.teal.fm/oauth/login?spa="+ "web" + "&handle="+ 72 - // handle.replace("@", ""), 73 - // { 74 - // method: "GET", 75 - // }, 76 - // ); 77 - 78 - // if (response.ok) { 79 - // // Handle redirect URL (json url param) 80 - // const j = await response.json(); 81 - // const redirectUrl = j.url; 82 - // setIsRedirecting(true); 83 - // if (!j.state) { 84 - // console.log("No state in response, redirecting to error page"); 85 - // router.replace("/error"); 86 - // return; 87 - // } 88 - // setAuthCode(j.state); 89 - // // Open the OAuth URL in the device's browser 90 - // await Linking.openURL(redirectUrl); 91 - 92 - // // Handle the callback URL when the user is redirected back 93 - // console.log("Setting up deep link subscription"); 94 - // const subscription = Linking.addEventListener("url", async (event) => { 95 - // console.log("Got a deep link event:", event); 96 - // if (event.url.includes("/oauth/callback") && j.state) { 97 - // console.log("Balls! state:", j.state); 98 - // // redirect to callback page, add state to url 99 - // router.navigate( 100 - // `/auth/callback?state=${encodeURIComponent(j.state)}`, 101 - // ); 102 - // subscription.remove(); 103 - // } 104 - // }); 105 - // } else { 106 - // const error = await response.json(); 107 - // Alert.alert("Error", error.error || "Failed to login"); 108 - // } 109 - // } catch (error: any) { 110 - // console.error("Network error!", error); 111 - // Alert.alert("Error", "Network error occurred:", error.message); 112 - // } finally { 113 - // setIsLoading(false); 114 - // } 115 - }; 116 - 117 - return ( 118 - <SafeAreaView className="flex-1 flex items-center justify-center w-full"> 119 - <Stack.Screen 120 - options={{ 121 - title: "Sign in", 122 - headerBackButtonDisplayMode: "minimal", 123 - headerShown: false, 124 - }} 125 - /> 126 - <View className="flex-1 justify-center align-center p-8 gap-4 pb-32 max-w-screen-sm min-w-full"> 127 - <View className="flex items-center"> 128 - <Icon icon={AtSign} className="color-bsky" name="at" size={64} /> 129 - </View> 130 - <Text className="text-3xl font-semibold text-center text-foreground"> 131 - Sign in with your PDS 132 - </Text> 133 - <View> 134 - <Text className="text-sm text-muted-foreground">Handle</Text> 135 - <Input 136 - className={err && `border-red-500 border-2`} 137 - placeholder="alice.bsky.social" 138 - value={handle} 139 - onChangeText={setHandle} 140 - autoCapitalize="none" 141 - autoCorrect={false} 142 - /> 143 - {err ? ( 144 - <Text className="text-red-500 justify-baseline mt-1 text-xs"> 145 - <Icon 146 - icon={AlertCircle} 147 - className="mr-1 inline -mt-0.5 text-xs" 148 - size={20} 149 - /> 150 - {err} 151 - </Text> 152 - ) : ( 153 - <View className="h-6" /> 154 - )} 155 - </View> 156 - <View className="flex flex-row justify-between items-center"> 157 - <Link href="https://bsky.app/signup"> 158 - <Text className="text-md ml-2 text-secondary"> 159 - Sign up for Bluesky 160 - </Text> 161 - </Link> 162 - <Button 163 - className={cn( 164 - "flex flex-row justify-end duration-500", 165 - isRedirecting ? "bg-green-500" : "bg-bsky" 166 - )} 167 - onPress={handleLogin} 168 - disabled={isLoading} 169 - > 170 - {isRedirecting ? ( 171 - <> 172 - <Text className="font-semibold text-lg">Redirecting</Text> 173 - <Icon icon={Check} /> 174 - </> 175 - ) : ( 176 - <> 177 - <Text className="font-semibold text-lg">Login</Text> 178 - <Icon icon={ChevronRight} /> 179 - </> 180 - )} 181 - </Button> 182 - </View> 183 - </View> 184 - </SafeAreaView> 185 - ); 186 - }; 187 - 188 - export default LoginScreen;
-17
apps/amethyst/app/modal.tsx
··· 1 - import { StatusBar } from "expo-status-bar"; 2 - import { Platform, StyleSheet } from "react-native"; 3 - 4 - import { View } from "react-native"; 5 - import { Text } from "../components/ui/text"; 6 - 7 - export default function ModalScreen() { 8 - return ( 9 - <View className="flex-1 items-center justify-center dark:bg-neutral-800 bg-neutral-100"> 10 - <Text className="text-neutral-200 text-4xl">HELLO WORLD !!!!</Text> 11 - <Text className="text-neutral-200 text-4xl">./app/modal.tsx</Text> 12 - 13 - {/* Use a light status bar on iOS to account for the black space above the modal */} 14 - <StatusBar style={Platform.OS === "ios" ? "light" : "auto"} /> 15 - </View> 16 - ); 17 - }
+8 -16
apps/amethyst/app/signup.tsx apps/amethyst/app/auth/signup.tsx
··· 1 - import React, { useEffect, useState } from "react"; 2 - import { 3 - View, 4 - TextInput, 5 - TouchableOpacity, 6 - Alert, 7 - Linking, 8 - } from "react-native"; 1 + import React from "react"; 2 + import { View } from "react-native"; 9 3 import { SafeAreaView } from "react-native-safe-area-context"; 10 - import { Text } from "../components/ui/text"; 11 - import { Button } from "../components/ui/button"; 12 - import { Card } from "../components/ui/card"; 13 - import { Icon } from "../lib/icons/iconWithClassName"; 14 - import { ArrowRight, Check, ChevronRight, Disc } from "lucide-react-native"; 15 - import { Input } from "../components/ui/input"; 16 - import { cn } from "../lib/utils"; 4 + import { Text } from "../../components/ui/text"; 5 + import { Button } from "../../components/ui/button"; 6 + import { Icon } from "../../lib/icons/iconWithClassName"; 7 + import { ArrowRight } from "lucide-react-native"; 8 + 17 9 import { Link, Stack, router } from "expo-router"; 18 10 import { FontAwesome6 } from "@expo/vector-icons"; 19 11 ··· 27 19 headerShown: false, 28 20 }} 29 21 /> 30 - <View className="flex-1 justify-center p-8 gap-4 pb-32 max-w-screen-sm"> 22 + <View className="flex-1 justify-center p-8 gap-4 pb-32 w-screen max-w-screen-md"> 31 23 <Text className="text-4xl font-semibold text-center text-foreground"> 32 24 Sign up with{" "} 33 25 <Icon
+23
apps/amethyst/hooks/useIsMobile.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { Platform } from "react-native"; 3 + 4 + export const isMobileInner = () => 5 + Platform.OS === "web" && window.innerWidth > 1024; 6 + 7 + export default function useIsMobile() { 8 + const [isMobile, setIsMobile] = useState(isMobileInner()); 9 + 10 + useEffect(() => { 11 + const handleResize = () => { 12 + setIsMobile(isMobileInner()); 13 + }; 14 + 15 + window.addEventListener("resize", handleResize); 16 + 17 + return () => { 18 + window.removeEventListener("resize", handleResize); 19 + }; 20 + }, []); 21 + 22 + return isMobile; 23 + }
+1
apps/amethyst/package.json
··· 21 21 "@atproto/oauth-client": "^0.3.5", 22 22 "@expo/vector-icons": "^14.0.4", 23 23 "@react-native-async-storage/async-storage": "1.23.1", 24 + "@react-native-picker/picker": "^2.11.0", 24 25 "@react-navigation/native": "^7.0.9", 25 26 "@rn-primitives/avatar": "^1.1.0", 26 27 "@rn-primitives/hover-card": "^1.1.0",
+14 -10
apps/amethyst/stores/authenticationSlice.tsx
··· 32 32 33 33 export const createAuthenticationSlice: StateCreator<AuthenticationSlice> = ( 34 34 set, 35 - get 35 + get, 36 36 ) => { 37 37 const initialAuth = createOAuthClient("http://localhost:8081"); 38 38 ··· 72 72 if (!(state.has("code") && state.has("state") && state.has("iss"))) { 73 73 throw new Error("Missing params, got: " + state); 74 74 } 75 - // are we already logged in? 75 + // are we already logged in? 76 76 if (get().status === "loggedIn") { 77 77 return; 78 78 } 79 - const { session, state: oauthState } = await initialAuth.callback(state); 79 + const { session, state: oauthState } = 80 + await initialAuth.callback(state); 80 81 const agent = new Agent(session); 81 82 set({ 82 83 oauthSession: session, ··· 127 128 } 128 129 }, 129 130 logOut: () => { 131 + console.log("Logging out"); 130 132 set({ 131 133 status: "loggedOut", 132 134 oauthSession: null, ··· 140 142 }; 141 143 142 144 function addDocs(agent: Agent) { 143 - Lexicons.schemas.filter((schema) => !schema.id.startsWith("app.bsky.")).map((schema) => { 144 - try { 145 - agent.lex.add(schema); 146 - } catch (e) { 147 - console.error("Failed to add schema:", e); 148 - } 149 - }); 145 + Lexicons.schemas 146 + .filter((schema) => !schema.id.startsWith("app.bsky.")) 147 + .map((schema) => { 148 + try { 149 + agent.lex.add(schema); 150 + } catch (e) { 151 + console.error("Failed to add schema:", e); 152 + } 153 + }); 150 154 return agent; 151 155 }