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

add viewing of other profiles

Natalie B. dbf0da17 5e690bf1

+318 -145
+38
apps/amethyst/app/(tabs)/profile/[handle].tsx
···
··· 1 + import ActorView from '@/components/actor/actorView'; 2 + import { Text } from '@/components/ui/text'; 3 + import { resolveHandle } from '@/lib/atp/pid'; 4 + import { useStore } from '@/stores/mainStore'; 5 + import { Stack, useLocalSearchParams } from 'expo-router'; 6 + import { useEffect, useState } from 'react'; 7 + import { ActivityIndicator, ScrollView, View } from 'react-native'; 8 + 9 + export default function Handle() { 10 + let { handle } = useLocalSearchParams(); 11 + 12 + let agent = useStore((state) => state.pdsAgent); 13 + 14 + // resolve handle 15 + const [did, setDid] = useState<string | null>(null); 16 + useEffect(() => { 17 + const fetchAgent = async () => { 18 + const agent = await resolveHandle(handle); 19 + setDid(agent); 20 + }; 21 + fetchAgent(); 22 + }, [handle]); 23 + 24 + if (!did) return <ActivityIndicator size="large" color="#0000ff" />; 25 + 26 + return ( 27 + <ScrollView className="flex-1 justify-start items-center gap-5 bg-background w-full"> 28 + <Stack.Screen 29 + options={{ 30 + title: 'Home', 31 + headerBackButtonDisplayMode: 'minimal', 32 + headerShown: false, 33 + }} 34 + /> 35 + <ActorView actorDid={did} pdsAgent={agent} /> 36 + </ScrollView> 37 + ); 38 + }
+43 -2
apps/amethyst/app/(tabs)/search/index.tsx
··· 1 import React, { useEffect, useState } from 'react'; 2 import { ScrollView, View } from 'react-native'; 3 - import { Stack } from 'expo-router'; 4 import { Input } from '@/components/ui/input'; 5 import { useStore } from '@/stores/mainStore'; 6 7 import { OutputSchema as SearchActorsOutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/actor/searchActors'; 8 import { MiniProfileView } from '@teal/lexicons/src/types/fm/teal/alpha/actor/defs'; 9 10 export default function Search() { 11 const [searchQuery, setSearchQuery] = React.useState(''); ··· 56 headerShown: false, 57 }} 58 /> 59 - <View className="max-w-2xl flex-1 w-screen flex flex-col p-4 divide-y divide-muted-foreground/50 gap-4 rounded-xl my-2 mx-5"> 60 <Input 61 placeholder="Search for users..." 62 value={searchQuery} 63 onChangeText={setSearchQuery} 64 /> 65 </View> 66 </ScrollView> 67 );
··· 1 import React, { useEffect, useState } from 'react'; 2 import { ScrollView, View } from 'react-native'; 3 + import { Link, Stack } from 'expo-router'; 4 import { Input } from '@/components/ui/input'; 5 + import { Text } from '@/components/ui/text'; 6 import { useStore } from '@/stores/mainStore'; 7 8 import { OutputSchema as SearchActorsOutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/actor/searchActors'; 9 import { MiniProfileView } from '@teal/lexicons/src/types/fm/teal/alpha/actor/defs'; 10 + import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 11 + import getImageCdnLink from '@/lib/atp/getImageCdnLink'; 12 13 export default function Search() { 14 const [searchQuery, setSearchQuery] = React.useState(''); ··· 59 headerShown: false, 60 }} 61 /> 62 + <View className="max-w-2xl flex-1 w-screen flex flex-col p-4 divide-y divide-muted-foreground/50 gap-4 rounded-xl my-2 mt-5"> 63 <Input 64 placeholder="Search for users..." 65 value={searchQuery} 66 onChangeText={setSearchQuery} 67 /> 68 + </View> 69 + <View className="my-2 mx-5"> 70 + {searchResults.map((user) => ( 71 + <Link 72 + href={`/profile/${user.handle?.replace('at://', '')}`} 73 + key={user.did} 74 + className="flex flex-row items-center gap-4 hover:bg-muted-foreground/20 p-2 rounded-xl" 75 + > 76 + <Avatar 77 + alt={`${user.displayName}'s profile`} 78 + className="w-14 h-14 border border-border" 79 + > 80 + <AvatarImage 81 + source={{ 82 + uri: 83 + user.avatar && 84 + getImageCdnLink({ 85 + did: user.did!, 86 + hash: user.avatar, 87 + }), 88 + }} 89 + /> 90 + <AvatarFallback> 91 + <Text> 92 + {user.displayName?.substring(0, 1) ?? 93 + user.handle?.substring(0, 1) ?? 94 + 'R'} 95 + </Text> 96 + </AvatarFallback> 97 + </Avatar> 98 + <View className="flex flex-col"> 99 + <Text className="font-semibold">{user.displayName}</Text> 100 + <Text className="text-muted-foreground"> 101 + {user.handle?.replace('at://', '@')} 102 + </Text> 103 + </View> 104 + </Link> 105 + ))} 106 </View> 107 </ScrollView> 108 );
+17 -17
apps/amethyst/app/auth/signup.tsx
··· 1 - import React from "react"; 2 - import { Platform, View } 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 { ArrowRight, AtSignIcon } from "lucide-react-native"; 8 9 - import { Stack, router } from "expo-router"; 10 11 const LoginScreen = () => { 12 return ( 13 <SafeAreaView className="flex-1 flex justify-center items-center"> 14 <Stack.Screen 15 options={{ 16 - title: "Sign in", 17 - headerBackButtonDisplayMode: "minimal", 18 headerShown: false, 19 }} 20 /> 21 <View className="flex-1 justify-center p-8 gap-4 pb-32 w-screen max-w-md"> 22 <Text className="text-3xl text-center text-foreground -mb-2"> 23 - Sign up via <br /> the{" "} 24 <Icon 25 icon={AtSignIcon} 26 - className="color-bsky inline mb-2" 27 size={32} 28 - />{" "} 29 Atmosphere 30 </Text> 31 <Text className="text-foreground text-xl text-center"> ··· 43 <Button 44 onPress={() => { 45 // on web, open new tab 46 - if (Platform.OS === "web") { 47 - window.open("https://bsky.app/signup", "_blank"); 48 } else { 49 - router.navigate("https://bsky.app"); 50 } 51 setTimeout(() => { 52 - router.replace("/auth/login"); 53 }, 1000); 54 }} 55 className="flex flex-row justify-center items-center gap-2 bg-bsky"
··· 1 + import React from 'react'; 2 + import { Platform, View } 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 { ArrowRight, AtSignIcon } from 'lucide-react-native'; 8 9 + import { Stack, router } from 'expo-router'; 10 11 const LoginScreen = () => { 12 return ( 13 <SafeAreaView className="flex-1 flex justify-center items-center"> 14 <Stack.Screen 15 options={{ 16 + title: 'Sign in', 17 + headerBackButtonDisplayMode: 'minimal', 18 headerShown: false, 19 }} 20 /> 21 <View className="flex-1 justify-center p-8 gap-4 pb-32 w-screen max-w-md"> 22 <Text className="text-3xl text-center text-foreground -mb-2"> 23 + Sign up via <br /> the{' '} 24 <Icon 25 icon={AtSignIcon} 26 + className="color-bsky inline mb-2 mr-1.5" 27 size={32} 28 + /> 29 Atmosphere 30 </Text> 31 <Text className="text-foreground text-xl text-center"> ··· 43 <Button 44 onPress={() => { 45 // on web, open new tab 46 + if (Platform.OS === 'web') { 47 + window.open('https://bsky.app/signup', '_blank'); 48 } else { 49 + router.navigate('https://bsky.app'); 50 } 51 setTimeout(() => { 52 + router.replace('/auth/login'); 53 }, 1000); 54 }} 55 className="flex flex-row justify-center items-center gap-2 bg-bsky"
+14 -6
apps/amethyst/app/onboarding/index.tsx
··· 1 import React, { useState } from 'react'; 2 - import { ActivityIndicator, ScrollView, View } from 'react-native'; 3 import { Text } from '@/components/ui/text'; // Your UI components 4 - import { Button } from '@/components/ui/button'; 5 import ImageSelectionPage from './imageSelectionPage'; // Separate page components 6 import DisplayNamePage from './displayNamePage'; 7 import DescriptionPage from './descriptionPage'; ··· 10 11 import { Record as ProfileRecord } from '@teal/lexicons/src/types/fm/teal/alpha/actor/profile'; 12 import { useStore } from '@/stores/mainStore'; 13 - import { Loader } from 'lucide-react-native'; 14 - import { navigate } from 'expo-router/build/global-state/routing'; 15 import { useRouter } from 'expo-router'; 16 17 const OnboardingSubmissionSteps: string[] = [ ··· 31 const [bannerUri, setBannerUri] = useState(''); 32 33 const [submissionStep, setSubmissionStep] = useState(1); 34 - const [submissionError, setSubmissionError] = useState(0); 35 36 const router = useRouter(); 37 38 const agent = useStore((store) => store.pdsAgent); 39 40 const handleImageSelectionComplete = (avatar: string, banner: string) => { 41 setAvatarUri(avatar); ··· 127 return <div>Loading...</div>; 128 } 129 130 if (submissionStep) { 131 return ( 132 <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> 133 <ActivityIndicator size="large" color="#0000ff" /> 134 - <Text>Profile updated successfully!</Text> 135 </View> 136 ); 137 }
··· 1 import React, { useState } from 'react'; 2 + import { ActivityIndicator, View } from 'react-native'; 3 import { Text } from '@/components/ui/text'; // Your UI components 4 import ImageSelectionPage from './imageSelectionPage'; // Separate page components 5 import DisplayNamePage from './displayNamePage'; 6 import DescriptionPage from './descriptionPage'; ··· 9 10 import { Record as ProfileRecord } from '@teal/lexicons/src/types/fm/teal/alpha/actor/profile'; 11 import { useStore } from '@/stores/mainStore'; 12 import { useRouter } from 'expo-router'; 13 14 const OnboardingSubmissionSteps: string[] = [ ··· 28 const [bannerUri, setBannerUri] = useState(''); 29 30 const [submissionStep, setSubmissionStep] = useState(1); 31 + const [submissionError, setSubmissionError] = useState(''); 32 33 const router = useRouter(); 34 35 const agent = useStore((store) => store.pdsAgent); 36 + const profile = useStore((store) => store.profiles); 37 38 const handleImageSelectionComplete = (avatar: string, banner: string) => { 39 setAvatarUri(avatar); ··· 125 return <div>Loading...</div>; 126 } 127 128 + // if we already have stuff then go back 129 + // 130 + if (profile[agent?.did!].teal) { 131 + return ( 132 + <Text> 133 + Profile already exists: {JSON.stringify(profile[agent?.did!].teal)} 134 + </Text> 135 + ); 136 + } 137 + 138 if (submissionStep) { 139 return ( 140 <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> 141 <ActivityIndicator size="large" color="#0000ff" /> 142 + <Text>{OnboardingSubmissionSteps[submissionStep]}</Text> 143 </View> 144 ); 145 }
+16 -6
apps/amethyst/components/actor/actorView.tsx
··· 1 - import { ScrollView, View, Image } from 'react-native'; 2 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 3 import { CardTitle } from '../../components/ui/card'; 4 import { Text } from '@/components/ui/text'; ··· 21 22 export interface ActorViewProps { 23 actorDid: string; 24 - pdsAgent: Agent; 25 } 26 27 export default function ActorView({ actorDid, pdsAgent }: ActorViewProps) { ··· 36 let isMounted = true; 37 38 const fetchProfile = async () => { 39 try { 40 let res = await pdsAgent.call( 41 'fm.teal.alpha.actor.getProfile', ··· 58 }; 59 }, [pdsAgent, actorDid, tealDid]); 60 61 - const isSelf = actorDid === pdsAgent.did; 62 63 const handleSave = async ( 64 updatedProfile: { displayName: any; description: any }, 65 newAvatarUri: string, 66 newBannerUri: string, 67 ) => { 68 // Implement your save logic here (e.g., update your database or state) 69 console.log('Saving profile:', updatedProfile, newAvatarUri, newBannerUri); 70 ··· 195 <Text>Edit</Text> 196 </Button> 197 ) : ( 198 - <Button variant="outline" size="sm" className=""> 199 <Icon icon={Plus} size={18} /> 200 <Text>Follow</Text> 201 </Button> ··· 224 </View> 225 <View className="max-w-2xl w-full gap-4 py-4 pl-8"> 226 <Text className="text-left text-2xl border-b border-b-muted-foreground/30 -ml-2 pl-2 mr-6"> 227 - Your Stamps 228 </Text> 229 - <ActorPlaysView repo={actorDid} /> 230 </View> 231 {isSelf && ( 232 <EditProfileModal
··· 1 + import { View, Image } from 'react-native'; 2 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 3 import { CardTitle } from '../../components/ui/card'; 4 import { Text } from '@/components/ui/text'; ··· 21 22 export interface ActorViewProps { 23 actorDid: string; 24 + pdsAgent: Agent | null; 25 } 26 27 export default function ActorView({ actorDid, pdsAgent }: ActorViewProps) { ··· 36 let isMounted = true; 37 38 const fetchProfile = async () => { 39 + if (!pdsAgent) { 40 + return; 41 + } 42 try { 43 let res = await pdsAgent.call( 44 'fm.teal.alpha.actor.getProfile', ··· 61 }; 62 }, [pdsAgent, actorDid, tealDid]); 63 64 + const isSelf = actorDid === (pdsAgent?.did || ""); 65 66 const handleSave = async ( 67 updatedProfile: { displayName: any; description: any }, 68 newAvatarUri: string, 69 newBannerUri: string, 70 ) => { 71 + if (!pdsAgent) { 72 + return; 73 + } 74 // Implement your save logic here (e.g., update your database or state) 75 console.log('Saving profile:', updatedProfile, newAvatarUri, newBannerUri); 76 ··· 201 <Text>Edit</Text> 202 </Button> 203 ) : ( 204 + <Button 205 + variant="outline" 206 + size="sm" 207 + className="rounded-xl flex-row gap-2 justify-center items-center" 208 + > 209 <Icon icon={Plus} size={18} /> 210 <Text>Follow</Text> 211 </Button> ··· 234 </View> 235 <View className="max-w-2xl w-full gap-4 py-4 pl-8"> 236 <Text className="text-left text-2xl border-b border-b-muted-foreground/30 -ml-2 pl-2 mr-6"> 237 + Stamps 238 </Text> 239 + <ActorPlaysView repo={actorDid} pdsAgent={pdsAgent} /> 240 </View> 241 {isSelf && ( 242 <EditProfileModal
+30 -30
apps/amethyst/components/play/actorPlaysView.tsx
··· 1 - import { useStore } from "@/stores/mainStore"; 2 - import { Record as Play } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play"; 3 - import { useEffect, useState } from "react"; 4 - import { ScrollView } from "react-native"; 5 - import { Text } from "@/components/ui/text"; 6 - import PlayView from "./playView"; 7 interface ActorPlaysViewProps { 8 repo: string | undefined; 9 - } 10 - interface PlayWrapper { 11 - cid: string; 12 - uri: string; 13 - value: Play; 14 } 15 - const ActorPlaysView = ({ repo }: ActorPlaysViewProps) => { 16 - const [play, setPlay] = useState<PlayWrapper[] | null>(null); 17 - const agent = useStore((state) => state.pdsAgent); 18 const isReady = useStore((state) => state.isAgentReady); 19 useEffect(() => { 20 - if (agent) { 21 - agent 22 - .call("com.atproto.repo.listRecords", { 23 - repo, 24 - collection: "fm.teal.alpha.feed.play", 25 - }) 26 - .then((profile) => { 27 - profile.data.records as PlayWrapper[]; 28 - return setPlay(profile.data.records); 29 }) 30 .catch((e) => { 31 console.log(e); 32 }); 33 } else { 34 - console.log("No agent"); 35 } 36 - }, [isReady, agent, repo]); 37 if (!play) { 38 return <Text>Loading...</Text>; 39 } ··· 41 <ScrollView className="w-full *:gap-4"> 42 {play.map((p) => ( 43 <PlayView 44 - key={p.uri} 45 - releaseTitle={p.value.releaseName} 46 - trackTitle={p.value.trackName} 47 - artistName={p.value.artistNames.join(", ")} 48 - releaseMbid={p.value.releaseMbId} 49 /> 50 ))} 51 </ScrollView>
··· 1 + import { useStore } from '@/stores/mainStore'; 2 + import { OutputSchema as ActorFeedResponse } from '@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed'; 3 + import { useEffect, useState } from 'react'; 4 + import { ScrollView } from 'react-native'; 5 + import { Text } from '@/components/ui/text'; 6 + import PlayView from './playView'; 7 + import { Agent } from '@atproto/api'; 8 + 9 interface ActorPlaysViewProps { 10 repo: string | undefined; 11 + pdsAgent: Agent | null; 12 } 13 + const ActorPlaysView = ({ repo, pdsAgent }: ActorPlaysViewProps) => { 14 + const [play, setPlay] = useState<ActorFeedResponse['plays'] | null>(null); 15 const isReady = useStore((state) => state.isAgentReady); 16 + const tealDid = useStore((state) => state.tealDid); 17 useEffect(() => { 18 + if (pdsAgent) { 19 + pdsAgent 20 + .call( 21 + 'fm.teal.alpha.feed.getActorFeed', 22 + { authorDID: repo }, 23 + {}, 24 + { headers: { 'atproto-proxy': tealDid + '#teal_fm_appview' } }, 25 + ) 26 + .then((res) => { 27 + res.data.plays as ActorFeedResponse; 28 + return setPlay(res.data.plays); 29 }) 30 .catch((e) => { 31 console.log(e); 32 }); 33 } else { 34 + console.log('No agent'); 35 } 36 + }, [isReady, pdsAgent, repo, tealDid]); 37 if (!play) { 38 return <Text>Loading...</Text>; 39 } ··· 41 <ScrollView className="w-full *:gap-4"> 42 {play.map((p) => ( 43 <PlayView 44 + key={p.playedTime + p.trackName} 45 + releaseTitle={p.releaseName} 46 + trackTitle={p.trackName} 47 + artistName={p.artistNames.join(', ')} 48 + releaseMbid={p.releaseMbId} 49 /> 50 ))} 51 </ScrollView>
+58
apps/amethyst/components/ui/ago.tsx
···
··· 1 + import { Text } from './text'; 2 + 3 + const Ago = ({ time }: { time: Date }) => { 4 + return ( 5 + <Text className="text-gray-500 text-sm">{timeAgoSinceDate(time)}</Text> 6 + ); 7 + }; 8 + 9 + /** 10 + * Calculates a human-readable string representing how long ago a date occurred relative to now. 11 + * Mimics the behavior of the provided Dart function. 12 + * 13 + * @param createdDate The date to compare against the current time. 14 + * @param numericDates If true, uses numeric representations like "1 minute ago", otherwise uses text like "A minute ago". Defaults to true. 15 + * @returns A string describing the time elapsed since the createdDate. 16 + */ 17 + function timeAgoSinceDate( 18 + createdDate: Date, 19 + numericDates: boolean = true, 20 + ): string { 21 + const now = new Date(); 22 + const differenceInMs = now.getTime() - createdDate.getTime(); 23 + 24 + const seconds = Math.floor(differenceInMs / 1000); 25 + const minutes = Math.floor(seconds / 60); 26 + const hours = Math.floor(minutes / 60); 27 + const days = Math.floor(hours / 24); 28 + 29 + if (seconds < 5) { 30 + return 'Just now'; 31 + } else if (seconds <= 60) { 32 + return `${seconds} seconds ago`; 33 + } else if (minutes <= 1) { 34 + return numericDates ? '1 minute ago' : 'A minute ago'; 35 + } else if (minutes <= 60) { 36 + return `${minutes} minutes ago`; 37 + } else if (hours <= 1) { 38 + return numericDates ? '1 hour ago' : 'An hour ago'; 39 + } else if (hours <= 60) { 40 + return `${hours} hours ago`; 41 + } else if (days <= 1) { 42 + return numericDates ? '1 day ago' : 'Yesterday'; 43 + } else if (days <= 6) { 44 + return `${days} days ago`; 45 + } else if (Math.ceil(days / 7) <= 1) { 46 + return numericDates ? '1 week ago' : 'Last week'; 47 + } else if (Math.ceil(days / 7) <= 4) { 48 + return `${Math.ceil(days / 7)} weeks ago`; 49 + } else if (Math.ceil(days / 30) <= 1) { 50 + return numericDates ? '1 month ago' : 'Last month'; 51 + } else if (Math.ceil(days / 30) <= 30) { 52 + return `${Math.ceil(days / 30)} months ago`; 53 + } else if (Math.ceil(days / 365) <= 1) { 54 + return numericDates ? '1 year ago' : 'Last year'; 55 + } else { 56 + return `${Math.floor(days / 365)} years ago`; 57 + } 58 + }
+52 -36
apps/amethyst/stores/authenticationSlice.tsx
··· 1 - import { StateCreator } from "./mainStore"; 2 - import createOAuthClient, { AquareumOAuthClient } from "../lib/atp/oauth"; 3 - import { OAuthSession } from "@atproto/oauth-client"; 4 - import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 5 - import { Agent } from "@atproto/api"; 6 - import * as Lexicons from "@teal/lexicons/src/lexicons"; 7 - import { resolveFromIdentity } from "@/lib/atp/pid"; 8 9 export interface AllProfileViews { 10 bsky: null | ProfileViewDetailed; 11 // todo: teal profile view 12 } 13 14 export interface AuthenticationSlice { 15 auth: AquareumOAuthClient; 16 - status: "start" | "loggedIn" | "loggedOut"; 17 oauthState: null | string; 18 oauthSession: null | OAuthSession; 19 pdsAgent: null | Agent; ··· 41 get, 42 ) => { 43 // check if we have CF_PAGES_URL set. if not, use localhost 44 - const baseUrl = process.env.EXPO_PUBLIC_BASE_URL || "http://localhost:8081"; 45 - console.log("Using base URL:", baseUrl); 46 - const initialAuth = createOAuthClient(baseUrl, "bsky.social"); 47 48 - console.log("Auth client created!"); 49 50 return { 51 auth: initialAuth, 52 - status: "start", 53 oauthState: null, 54 oauthSession: null, 55 pdsAgent: null, ··· 78 }); 79 return url; 80 } catch (error) { 81 - console.error("Failed to get login URL:", error); 82 return null; 83 } 84 }, 85 86 oauthCallback: async (state: URLSearchParams) => { 87 try { 88 - if (!(state.has("code") && state.has("state") && state.has("iss"))) { 89 - throw new Error("Missing params, got: " + state); 90 } 91 // are we already logged in? 92 - if (get().status === "loggedIn") { 93 return; 94 } 95 const { session, state: oauthState } = 96 await initialAuth.callback(state); 97 const agent = new Agent(session); 98 set({ 99 - oauthSession: session, 100 oauthState, 101 - status: "loggedIn", 102 pdsAgent: addDocs(agent), 103 isAgentReady: true, 104 }); 105 get().populateLoggedInProfile(); 106 } catch (error: any) { 107 - console.error("OAuth callback failed:", error); 108 set({ 109 - status: "loggedOut", 110 login: { 111 loading: false, 112 error: 113 (error?.message as string) || 114 - "Unknown error during OAuth callback", 115 }, 116 }); 117 } ··· 128 let sess = await initialAuth.restore(did); 129 130 if (!sess) { 131 - throw new Error("Failed to restore session"); 132 } 133 134 const agent = new Agent(sess); ··· 136 set({ 137 pdsAgent: addDocs(agent), 138 isAgentReady: true, 139 - status: "loggedIn", 140 }); 141 get().populateLoggedInProfile(); 142 - console.log("Restored agent"); 143 } catch (error) { 144 - console.error("Failed to restore agent:", error); 145 get().logOut(); 146 } 147 }, 148 logOut: () => { 149 - console.log("Logging out"); 150 let profiles = { ...get().profiles }; 151 // TODO: something better than 'delete' 152 - delete profiles[get().pdsAgent?.did ?? ""]; 153 set({ 154 - status: "loggedOut", 155 oauthSession: null, 156 oauthState: null, 157 profiles, ··· 161 }); 162 }, 163 populateLoggedInProfile: async () => { 164 - console.log("Populating logged in profile"); 165 const agent = get().pdsAgent; 166 if (!agent) { 167 - throw new Error("No agent"); 168 } 169 if (!agent.did) { 170 - throw new Error("No agent did! This is bad!"); 171 } 172 try { 173 let bskyProfile = await agent ··· 176 console.log(profile); 177 return profile.data || null; 178 }); 179 180 set({ 181 profiles: { 182 - [agent.did]: { bsky: bskyProfile }, 183 }, 184 }); 185 } catch (error) { 186 - console.error("Failed to get profile:", error); 187 } 188 }, 189 }; ··· 191 192 function addDocs(agent: Agent) { 193 Lexicons.schemas 194 - .filter((schema) => !schema.id.startsWith("app.bsky.")) 195 .map((schema) => { 196 try { 197 agent.lex.add(schema); 198 } catch (e) { 199 - console.error("Failed to add schema:", e); 200 } 201 }); 202 return agent;
··· 1 + import { StateCreator } from './mainStore'; 2 + import createOAuthClient, { AquareumOAuthClient } from '../lib/atp/oauth'; 3 + import { OAuthSession } from '@atproto/oauth-client'; 4 + import { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 5 + import { OutputSchema as GetProfileOutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/actor/getProfile'; 6 + import { Agent } from '@atproto/api'; 7 + import * as Lexicons from '@teal/lexicons/src/lexicons'; 8 + import { resolveFromIdentity } from '@/lib/atp/pid'; 9 10 export interface AllProfileViews { 11 bsky: null | ProfileViewDetailed; 12 + teal: null | GetProfileOutputSchema['actor']; 13 // todo: teal profile view 14 } 15 16 export interface AuthenticationSlice { 17 auth: AquareumOAuthClient; 18 + status: 'start' | 'loggedIn' | 'loggedOut'; 19 oauthState: null | string; 20 oauthSession: null | OAuthSession; 21 pdsAgent: null | Agent; ··· 43 get, 44 ) => { 45 // check if we have CF_PAGES_URL set. if not, use localhost 46 + const baseUrl = process.env.EXPO_PUBLIC_BASE_URL || 'http://localhost:8081'; 47 + console.log('Using base URL:', baseUrl); 48 + const initialAuth = createOAuthClient(baseUrl, 'bsky.social'); 49 50 + console.log('Auth client created!'); 51 52 return { 53 auth: initialAuth, 54 + status: 'start', 55 oauthState: null, 56 oauthSession: null, 57 pdsAgent: null, ··· 80 }); 81 return url; 82 } catch (error) { 83 + console.error('Failed to get login URL:', error); 84 return null; 85 } 86 }, 87 88 oauthCallback: async (state: URLSearchParams) => { 89 try { 90 + if (!(state.has('code') && state.has('state') && state.has('iss'))) { 91 + throw new Error('Missing params, got: ' + state); 92 } 93 // are we already logged in? 94 + if (get().status === 'loggedIn') { 95 return; 96 } 97 const { session, state: oauthState } = 98 await initialAuth.callback(state); 99 const agent = new Agent(session); 100 set({ 101 + // TODO: fork or update auth lib 102 + oauthSession: session as any, 103 oauthState, 104 + status: 'loggedIn', 105 pdsAgent: addDocs(agent), 106 isAgentReady: true, 107 }); 108 get().populateLoggedInProfile(); 109 } catch (error: any) { 110 + console.error('OAuth callback failed:', error); 111 set({ 112 + status: 'loggedOut', 113 login: { 114 loading: false, 115 error: 116 (error?.message as string) || 117 + 'Unknown error during OAuth callback', 118 }, 119 }); 120 } ··· 131 let sess = await initialAuth.restore(did); 132 133 if (!sess) { 134 + throw new Error('Failed to restore session'); 135 } 136 137 const agent = new Agent(sess); ··· 139 set({ 140 pdsAgent: addDocs(agent), 141 isAgentReady: true, 142 + status: 'loggedIn', 143 }); 144 get().populateLoggedInProfile(); 145 + console.log('Restored agent'); 146 } catch (error) { 147 + console.error('Failed to restore agent:', error); 148 get().logOut(); 149 } 150 }, 151 logOut: () => { 152 + console.log('Logging out'); 153 let profiles = { ...get().profiles }; 154 // TODO: something better than 'delete' 155 + delete profiles[get().pdsAgent?.did ?? '']; 156 set({ 157 + status: 'loggedOut', 158 oauthSession: null, 159 oauthState: null, 160 profiles, ··· 164 }); 165 }, 166 populateLoggedInProfile: async () => { 167 + console.log('Populating logged in profile'); 168 const agent = get().pdsAgent; 169 if (!agent) { 170 + throw new Error('No agent'); 171 } 172 if (!agent.did) { 173 + throw new Error('No agent did! This is bad!'); 174 } 175 try { 176 let bskyProfile = await agent ··· 179 console.log(profile); 180 return profile.data || null; 181 }); 182 + // get teal did 183 + let tealDid = get().tealDid; 184 + let tealProfile = await agent 185 + .call( 186 + 'fm.teal.alpha.actor.getProfile', 187 + { actor: agent?.did }, 188 + {}, 189 + { headers: { 'atproto-proxy': tealDid + '#teal_fm_appview' } }, 190 + ) 191 + .then((profile) => { 192 + console.log(profile); 193 + return profile.data.agent || null; 194 + }); 195 196 set({ 197 profiles: { 198 + [agent.did]: { bsky: bskyProfile, teal: tealProfile }, 199 }, 200 }); 201 } catch (error) { 202 + console.error('Failed to get profile:', error); 203 } 204 }, 205 }; ··· 207 208 function addDocs(agent: Agent) { 209 Lexicons.schemas 210 + .filter((schema) => !schema.id.startsWith('app.bsky.')) 211 .map((schema) => { 212 try { 213 agent.lex.add(schema); 214 } catch (e) { 215 + console.error('Failed to add schema:', e); 216 } 217 }); 218 return agent;
+50 -48
apps/aqua/src/xrpc/feed/getActorFeed.ts
··· 1 - import { TealContext } from "@/ctx"; 2 - import { artists, db, plays, playToArtists } from "@teal/db"; 3 - import { eq, and, lt, desc, sql } from "drizzle-orm"; 4 - import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed"; 5 6 export default async function getActorFeed(c: TealContext) { 7 const params = c.req.query(); 8 - if (!params.authorDid) { 9 - throw new Error("authorDid is required"); 10 } 11 12 let limit = 20; 13 14 if (params.limit) { 15 limit = Number(params.limit); 16 - if (limit > 50) throw new Error("Limit is over max allowed."); 17 } 18 19 // 'and' is here for typing reasons 20 - let whereClause = and(eq(plays.did, params.authorDid)); 21 22 // Add cursor pagination if provided 23 if (params.cursor) { ··· 30 const cursorPlay = cursorResult[0]?.playedTime; 31 32 if (!cursorPlay) { 33 - throw new Error("Cursor not found"); 34 } 35 36 whereClause = and(whereClause, lt(plays.playedTime, cursorPlay as any)); ··· 53 submissionClientAgent: plays.submissionClientAgent, 54 musicServiceBaseDomain: plays.musicServiceBaseDomain, 55 artists: sql<Array<{ mbid: string; name: string }>>` 56 - COALESCE 57 - array_agg( 58 - CASE WHEN ${playToArtists.artistMbid} IS NOT NULL THEN 59 - jsonb_build_object( 60 - 'mbid', ${playToArtists.artistMbid}, 61 - 'name', ${playToArtists.artistName} 62 - ) 63 - END 64 - ) FILTER (WHERE ${playToArtists.artistName} IS NOT NULL), 65 - ARRAY[]::jsonb[] 66 - ) 67 - `.as("artists"), 68 }) 69 .from(plays) 70 .leftJoin(playToArtists, sql`${plays.uri} = ${playToArtists.playUri}`) ··· 88 ) 89 .orderBy(desc(plays.playedTime)) 90 .limit(limit); 91 - 92 - if (playRes.length === 0) { 93 - throw new Error("Play not found"); 94 - } 95 96 return { 97 plays: playRes.map( 98 ({ 99 - uri, 100 - did: authorDid, 101 - processedTime: createdAt, 102 - processedTime: indexedAt, 103 trackName, 104 - cid: trackMbId, 105 recordingMbid, 106 duration, 107 - artists, 108 releaseName, 109 releaseMbid, 110 isrc, ··· 112 musicServiceBaseDomain, 113 submissionClientAgent, 114 playedTime, 115 }) => ({ 116 - uri, 117 - authorDid, 118 - createdAt: createdAt?.toISOString(), 119 - indexedAt: indexedAt?.toISOString(), 120 - trackName, 121 - trackMbId, 122 - recordingMbId: recordingMbid, 123 - duration, 124 - artistNames: artists.map((artist) => artist.name), 125 - artistMbIds: artists.map((artist) => artist.mbid), 126 - releaseName, 127 - releaseMbId: releaseMbid, 128 - isrc, 129 - originUrl, 130 - musicServiceBaseDomain, 131 - submissionClientAgent, 132 - playedTime: playedTime?.toISOString(), 133 }), 134 ), 135 } as OutputSchema; 136 }
··· 1 + import { TealContext } from '@/ctx'; 2 + import { artists, db, plays, playToArtists } from '@teal/db'; 3 + import { eq, and, lt, desc, sql } from 'drizzle-orm'; 4 + import { OutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed'; 5 6 export default async function getActorFeed(c: TealContext) { 7 const params = c.req.query(); 8 + if (!params.authorDID) { 9 + throw new Error('authorDID is required'); 10 } 11 12 let limit = 20; 13 14 if (params.limit) { 15 limit = Number(params.limit); 16 + if (limit > 50) throw new Error('Limit is over max allowed.'); 17 } 18 19 // 'and' is here for typing reasons 20 + let whereClause = and(eq(plays.did, params.authorDID)); 21 22 // Add cursor pagination if provided 23 if (params.cursor) { ··· 30 const cursorPlay = cursorResult[0]?.playedTime; 31 32 if (!cursorPlay) { 33 + throw new Error('Cursor not found'); 34 } 35 36 whereClause = and(whereClause, lt(plays.playedTime, cursorPlay as any)); ··· 53 submissionClientAgent: plays.submissionClientAgent, 54 musicServiceBaseDomain: plays.musicServiceBaseDomain, 55 artists: sql<Array<{ mbid: string; name: string }>>` 56 + COALESCE( 57 + ( 58 + SELECT jsonb_agg(jsonb_build_object('mbid', pa.artist_mbid, 'name', pa.artist_name)) 59 + FROM ${playToArtists} pa 60 + WHERE pa.play_uri = ${plays.uri} 61 + AND pa.artist_mbid IS NOT NULL 62 + AND pa.artist_name IS NOT NULL -- Ensure both are non-null 63 + ), 64 + '[]'::jsonb -- Correct empty JSONB array literal 65 + )`.as('artists'), 66 }) 67 .from(plays) 68 .leftJoin(playToArtists, sql`${plays.uri} = ${playToArtists.playUri}`) ··· 86 ) 87 .orderBy(desc(plays.playedTime)) 88 .limit(limit); 89 + const cursor = 90 + playRes.length === limit ? playRes[playRes.length - 1]?.uri : undefined; 91 92 return { 93 + cursor: cursor ?? undefined, // Ensure cursor itself can be undefined 94 plays: playRes.map( 95 ({ 96 + // Destructure fields from the DB result 97 trackName, 98 + cid: trackMbId, // Note the alias was used here in the DB query select 99 recordingMbid, 100 duration, 101 + artists, // This is guaranteed to be an array '[]' if no artists, due to COALESCE 102 releaseName, 103 releaseMbid, 104 isrc, ··· 106 musicServiceBaseDomain, 107 submissionClientAgent, 108 playedTime, 109 + // Other destructured fields like uri, did, etc. are not directly used here by name 110 }) => ({ 111 + // Apply '?? undefined' to each potentially nullable/undefined scalar field 112 + trackName: trackName ?? undefined, 113 + trackMbId: trackMbId ?? undefined, 114 + recordingMbId: recordingMbid ?? undefined, 115 + duration: duration ?? undefined, 116 + 117 + // For arrays derived from a guaranteed array, map is safe. 118 + // The SQL query ensures `artists` is '[]'::jsonb if empty. 119 + // The SQL query also ensures artist.name/mbid are NOT NULL within the jsonb_agg 120 + artistNames: artists.map((artist) => artist.name), // Will be [] if artists is [] 121 + artistMbIds: artists.map((artist) => artist.mbid), // Will be [] if artists is [] 122 + 123 + releaseName: releaseName ?? undefined, 124 + releaseMbId: releaseMbid ?? undefined, 125 + isrc: isrc ?? undefined, 126 + originUrl: originUrl ?? undefined, 127 + musicServiceBaseDomain: musicServiceBaseDomain ?? undefined, 128 + submissionClientAgent: submissionClientAgent ?? undefined, 129 + 130 + // playedTime specific handling: convert to ISO string if exists, else undefined 131 + playedTime: playedTime ? playedTime.toISOString() : undefined, 132 + // Alternative using optional chaining (effectively the same) 133 + // playedTime: playedTime?.toISOString(), 134 }), 135 ), 136 + // Explicitly cast to OutputSchema. Make sure OutputSchema allows undefined for these fields. 137 } as OutputSchema; 138 }