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 1 import React, { useEffect, useState } from 'react'; 2 2 import { ScrollView, View } from 'react-native'; 3 - import { Stack } from 'expo-router'; 3 + import { Link, Stack } from 'expo-router'; 4 4 import { Input } from '@/components/ui/input'; 5 + import { Text } from '@/components/ui/text'; 5 6 import { useStore } from '@/stores/mainStore'; 6 7 7 8 import { OutputSchema as SearchActorsOutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/actor/searchActors'; 8 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'; 9 12 10 13 export default function Search() { 11 14 const [searchQuery, setSearchQuery] = React.useState(''); ··· 56 59 headerShown: false, 57 60 }} 58 61 /> 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"> 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"> 60 63 <Input 61 64 placeholder="Search for users..." 62 65 value={searchQuery} 63 66 onChangeText={setSearchQuery} 64 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 + ))} 65 106 </View> 66 107 </ScrollView> 67 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"; 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 8 9 - import { Stack, router } from "expo-router"; 9 + import { Stack, router } from 'expo-router'; 10 10 11 11 const LoginScreen = () => { 12 12 return ( 13 13 <SafeAreaView className="flex-1 flex justify-center items-center"> 14 14 <Stack.Screen 15 15 options={{ 16 - title: "Sign in", 17 - headerBackButtonDisplayMode: "minimal", 16 + title: 'Sign in', 17 + headerBackButtonDisplayMode: 'minimal', 18 18 headerShown: false, 19 19 }} 20 20 /> 21 21 <View className="flex-1 justify-center p-8 gap-4 pb-32 w-screen max-w-md"> 22 22 <Text className="text-3xl text-center text-foreground -mb-2"> 23 - Sign up via <br /> the{" "} 23 + Sign up via <br /> the{' '} 24 24 <Icon 25 25 icon={AtSignIcon} 26 - className="color-bsky inline mb-2" 26 + className="color-bsky inline mb-2 mr-1.5" 27 27 size={32} 28 - />{" "} 28 + /> 29 29 Atmosphere 30 30 </Text> 31 31 <Text className="text-foreground text-xl text-center"> ··· 43 43 <Button 44 44 onPress={() => { 45 45 // on web, open new tab 46 - if (Platform.OS === "web") { 47 - window.open("https://bsky.app/signup", "_blank"); 46 + if (Platform.OS === 'web') { 47 + window.open('https://bsky.app/signup', '_blank'); 48 48 } else { 49 - router.navigate("https://bsky.app"); 49 + router.navigate('https://bsky.app'); 50 50 } 51 51 setTimeout(() => { 52 - router.replace("/auth/login"); 52 + router.replace('/auth/login'); 53 53 }, 1000); 54 54 }} 55 55 className="flex flex-row justify-center items-center gap-2 bg-bsky"
+14 -6
apps/amethyst/app/onboarding/index.tsx
··· 1 1 import React, { useState } from 'react'; 2 - import { ActivityIndicator, ScrollView, View } from 'react-native'; 2 + import { ActivityIndicator, View } from 'react-native'; 3 3 import { Text } from '@/components/ui/text'; // Your UI components 4 - import { Button } from '@/components/ui/button'; 5 4 import ImageSelectionPage from './imageSelectionPage'; // Separate page components 6 5 import DisplayNamePage from './displayNamePage'; 7 6 import DescriptionPage from './descriptionPage'; ··· 10 9 11 10 import { Record as ProfileRecord } from '@teal/lexicons/src/types/fm/teal/alpha/actor/profile'; 12 11 import { useStore } from '@/stores/mainStore'; 13 - import { Loader } from 'lucide-react-native'; 14 - import { navigate } from 'expo-router/build/global-state/routing'; 15 12 import { useRouter } from 'expo-router'; 16 13 17 14 const OnboardingSubmissionSteps: string[] = [ ··· 31 28 const [bannerUri, setBannerUri] = useState(''); 32 29 33 30 const [submissionStep, setSubmissionStep] = useState(1); 34 - const [submissionError, setSubmissionError] = useState(0); 31 + const [submissionError, setSubmissionError] = useState(''); 35 32 36 33 const router = useRouter(); 37 34 38 35 const agent = useStore((store) => store.pdsAgent); 36 + const profile = useStore((store) => store.profiles); 39 37 40 38 const handleImageSelectionComplete = (avatar: string, banner: string) => { 41 39 setAvatarUri(avatar); ··· 127 125 return <div>Loading...</div>; 128 126 } 129 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 + 130 138 if (submissionStep) { 131 139 return ( 132 140 <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> 133 141 <ActivityIndicator size="large" color="#0000ff" /> 134 - <Text>Profile updated successfully!</Text> 142 + <Text>{OnboardingSubmissionSteps[submissionStep]}</Text> 135 143 </View> 136 144 ); 137 145 }
+16 -6
apps/amethyst/components/actor/actorView.tsx
··· 1 - import { ScrollView, View, Image } from 'react-native'; 1 + import { View, Image } from 'react-native'; 2 2 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 3 3 import { CardTitle } from '../../components/ui/card'; 4 4 import { Text } from '@/components/ui/text'; ··· 21 21 22 22 export interface ActorViewProps { 23 23 actorDid: string; 24 - pdsAgent: Agent; 24 + pdsAgent: Agent | null; 25 25 } 26 26 27 27 export default function ActorView({ actorDid, pdsAgent }: ActorViewProps) { ··· 36 36 let isMounted = true; 37 37 38 38 const fetchProfile = async () => { 39 + if (!pdsAgent) { 40 + return; 41 + } 39 42 try { 40 43 let res = await pdsAgent.call( 41 44 'fm.teal.alpha.actor.getProfile', ··· 58 61 }; 59 62 }, [pdsAgent, actorDid, tealDid]); 60 63 61 - const isSelf = actorDid === pdsAgent.did; 64 + const isSelf = actorDid === (pdsAgent?.did || ""); 62 65 63 66 const handleSave = async ( 64 67 updatedProfile: { displayName: any; description: any }, 65 68 newAvatarUri: string, 66 69 newBannerUri: string, 67 70 ) => { 71 + if (!pdsAgent) { 72 + return; 73 + } 68 74 // Implement your save logic here (e.g., update your database or state) 69 75 console.log('Saving profile:', updatedProfile, newAvatarUri, newBannerUri); 70 76 ··· 195 201 <Text>Edit</Text> 196 202 </Button> 197 203 ) : ( 198 - <Button variant="outline" size="sm" className=""> 204 + <Button 205 + variant="outline" 206 + size="sm" 207 + className="rounded-xl flex-row gap-2 justify-center items-center" 208 + > 199 209 <Icon icon={Plus} size={18} /> 200 210 <Text>Follow</Text> 201 211 </Button> ··· 224 234 </View> 225 235 <View className="max-w-2xl w-full gap-4 py-4 pl-8"> 226 236 <Text className="text-left text-2xl border-b border-b-muted-foreground/30 -ml-2 pl-2 mr-6"> 227 - Your Stamps 237 + Stamps 228 238 </Text> 229 - <ActorPlaysView repo={actorDid} /> 239 + <ActorPlaysView repo={actorDid} pdsAgent={pdsAgent} /> 230 240 </View> 231 241 {isSelf && ( 232 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"; 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 + 7 9 interface ActorPlaysViewProps { 8 10 repo: string | undefined; 9 - } 10 - interface PlayWrapper { 11 - cid: string; 12 - uri: string; 13 - value: Play; 11 + pdsAgent: Agent | null; 14 12 } 15 - const ActorPlaysView = ({ repo }: ActorPlaysViewProps) => { 16 - const [play, setPlay] = useState<PlayWrapper[] | null>(null); 17 - const agent = useStore((state) => state.pdsAgent); 13 + const ActorPlaysView = ({ repo, pdsAgent }: ActorPlaysViewProps) => { 14 + const [play, setPlay] = useState<ActorFeedResponse['plays'] | null>(null); 18 15 const isReady = useStore((state) => state.isAgentReady); 16 + const tealDid = useStore((state) => state.tealDid); 19 17 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); 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 29 }) 30 30 .catch((e) => { 31 31 console.log(e); 32 32 }); 33 33 } else { 34 - console.log("No agent"); 34 + console.log('No agent'); 35 35 } 36 - }, [isReady, agent, repo]); 36 + }, [isReady, pdsAgent, repo, tealDid]); 37 37 if (!play) { 38 38 return <Text>Loading...</Text>; 39 39 } ··· 41 41 <ScrollView className="w-full *:gap-4"> 42 42 {play.map((p) => ( 43 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} 44 + key={p.playedTime + p.trackName} 45 + releaseTitle={p.releaseName} 46 + trackTitle={p.trackName} 47 + artistName={p.artistNames.join(', ')} 48 + releaseMbid={p.releaseMbId} 49 49 /> 50 50 ))} 51 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"; 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'; 8 9 9 10 export interface AllProfileViews { 10 11 bsky: null | ProfileViewDetailed; 12 + teal: null | GetProfileOutputSchema['actor']; 11 13 // todo: teal profile view 12 14 } 13 15 14 16 export interface AuthenticationSlice { 15 17 auth: AquareumOAuthClient; 16 - status: "start" | "loggedIn" | "loggedOut"; 18 + status: 'start' | 'loggedIn' | 'loggedOut'; 17 19 oauthState: null | string; 18 20 oauthSession: null | OAuthSession; 19 21 pdsAgent: null | Agent; ··· 41 43 get, 42 44 ) => { 43 45 // 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"); 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'); 47 49 48 - console.log("Auth client created!"); 50 + console.log('Auth client created!'); 49 51 50 52 return { 51 53 auth: initialAuth, 52 - status: "start", 54 + status: 'start', 53 55 oauthState: null, 54 56 oauthSession: null, 55 57 pdsAgent: null, ··· 78 80 }); 79 81 return url; 80 82 } catch (error) { 81 - console.error("Failed to get login URL:", error); 83 + console.error('Failed to get login URL:', error); 82 84 return null; 83 85 } 84 86 }, 85 87 86 88 oauthCallback: async (state: URLSearchParams) => { 87 89 try { 88 - if (!(state.has("code") && state.has("state") && state.has("iss"))) { 89 - throw new Error("Missing params, got: " + state); 90 + if (!(state.has('code') && state.has('state') && state.has('iss'))) { 91 + throw new Error('Missing params, got: ' + state); 90 92 } 91 93 // are we already logged in? 92 - if (get().status === "loggedIn") { 94 + if (get().status === 'loggedIn') { 93 95 return; 94 96 } 95 97 const { session, state: oauthState } = 96 98 await initialAuth.callback(state); 97 99 const agent = new Agent(session); 98 100 set({ 99 - oauthSession: session, 101 + // TODO: fork or update auth lib 102 + oauthSession: session as any, 100 103 oauthState, 101 - status: "loggedIn", 104 + status: 'loggedIn', 102 105 pdsAgent: addDocs(agent), 103 106 isAgentReady: true, 104 107 }); 105 108 get().populateLoggedInProfile(); 106 109 } catch (error: any) { 107 - console.error("OAuth callback failed:", error); 110 + console.error('OAuth callback failed:', error); 108 111 set({ 109 - status: "loggedOut", 112 + status: 'loggedOut', 110 113 login: { 111 114 loading: false, 112 115 error: 113 116 (error?.message as string) || 114 - "Unknown error during OAuth callback", 117 + 'Unknown error during OAuth callback', 115 118 }, 116 119 }); 117 120 } ··· 128 131 let sess = await initialAuth.restore(did); 129 132 130 133 if (!sess) { 131 - throw new Error("Failed to restore session"); 134 + throw new Error('Failed to restore session'); 132 135 } 133 136 134 137 const agent = new Agent(sess); ··· 136 139 set({ 137 140 pdsAgent: addDocs(agent), 138 141 isAgentReady: true, 139 - status: "loggedIn", 142 + status: 'loggedIn', 140 143 }); 141 144 get().populateLoggedInProfile(); 142 - console.log("Restored agent"); 145 + console.log('Restored agent'); 143 146 } catch (error) { 144 - console.error("Failed to restore agent:", error); 147 + console.error('Failed to restore agent:', error); 145 148 get().logOut(); 146 149 } 147 150 }, 148 151 logOut: () => { 149 - console.log("Logging out"); 152 + console.log('Logging out'); 150 153 let profiles = { ...get().profiles }; 151 154 // TODO: something better than 'delete' 152 - delete profiles[get().pdsAgent?.did ?? ""]; 155 + delete profiles[get().pdsAgent?.did ?? '']; 153 156 set({ 154 - status: "loggedOut", 157 + status: 'loggedOut', 155 158 oauthSession: null, 156 159 oauthState: null, 157 160 profiles, ··· 161 164 }); 162 165 }, 163 166 populateLoggedInProfile: async () => { 164 - console.log("Populating logged in profile"); 167 + console.log('Populating logged in profile'); 165 168 const agent = get().pdsAgent; 166 169 if (!agent) { 167 - throw new Error("No agent"); 170 + throw new Error('No agent'); 168 171 } 169 172 if (!agent.did) { 170 - throw new Error("No agent did! This is bad!"); 173 + throw new Error('No agent did! This is bad!'); 171 174 } 172 175 try { 173 176 let bskyProfile = await agent ··· 176 179 console.log(profile); 177 180 return profile.data || null; 178 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 + }); 179 195 180 196 set({ 181 197 profiles: { 182 - [agent.did]: { bsky: bskyProfile }, 198 + [agent.did]: { bsky: bskyProfile, teal: tealProfile }, 183 199 }, 184 200 }); 185 201 } catch (error) { 186 - console.error("Failed to get profile:", error); 202 + console.error('Failed to get profile:', error); 187 203 } 188 204 }, 189 205 }; ··· 191 207 192 208 function addDocs(agent: Agent) { 193 209 Lexicons.schemas 194 - .filter((schema) => !schema.id.startsWith("app.bsky.")) 210 + .filter((schema) => !schema.id.startsWith('app.bsky.')) 195 211 .map((schema) => { 196 212 try { 197 213 agent.lex.add(schema); 198 214 } catch (e) { 199 - console.error("Failed to add schema:", e); 215 + console.error('Failed to add schema:', e); 200 216 } 201 217 }); 202 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"; 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 5 6 6 export default async function getActorFeed(c: TealContext) { 7 7 const params = c.req.query(); 8 - if (!params.authorDid) { 9 - throw new Error("authorDid is required"); 8 + if (!params.authorDID) { 9 + throw new Error('authorDID is required'); 10 10 } 11 11 12 12 let limit = 20; 13 13 14 14 if (params.limit) { 15 15 limit = Number(params.limit); 16 - if (limit > 50) throw new Error("Limit is over max allowed."); 16 + if (limit > 50) throw new Error('Limit is over max allowed.'); 17 17 } 18 18 19 19 // 'and' is here for typing reasons 20 - let whereClause = and(eq(plays.did, params.authorDid)); 20 + let whereClause = and(eq(plays.did, params.authorDID)); 21 21 22 22 // Add cursor pagination if provided 23 23 if (params.cursor) { ··· 30 30 const cursorPlay = cursorResult[0]?.playedTime; 31 31 32 32 if (!cursorPlay) { 33 - throw new Error("Cursor not found"); 33 + throw new Error('Cursor not found'); 34 34 } 35 35 36 36 whereClause = and(whereClause, lt(plays.playedTime, cursorPlay as any)); ··· 53 53 submissionClientAgent: plays.submissionClientAgent, 54 54 musicServiceBaseDomain: plays.musicServiceBaseDomain, 55 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"), 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'), 68 66 }) 69 67 .from(plays) 70 68 .leftJoin(playToArtists, sql`${plays.uri} = ${playToArtists.playUri}`) ··· 88 86 ) 89 87 .orderBy(desc(plays.playedTime)) 90 88 .limit(limit); 91 - 92 - if (playRes.length === 0) { 93 - throw new Error("Play not found"); 94 - } 89 + const cursor = 90 + playRes.length === limit ? playRes[playRes.length - 1]?.uri : undefined; 95 91 96 92 return { 93 + cursor: cursor ?? undefined, // Ensure cursor itself can be undefined 97 94 plays: playRes.map( 98 95 ({ 99 - uri, 100 - did: authorDid, 101 - processedTime: createdAt, 102 - processedTime: indexedAt, 96 + // Destructure fields from the DB result 103 97 trackName, 104 - cid: trackMbId, 98 + cid: trackMbId, // Note the alias was used here in the DB query select 105 99 recordingMbid, 106 100 duration, 107 - artists, 101 + artists, // This is guaranteed to be an array '[]' if no artists, due to COALESCE 108 102 releaseName, 109 103 releaseMbid, 110 104 isrc, ··· 112 106 musicServiceBaseDomain, 113 107 submissionClientAgent, 114 108 playedTime, 109 + // Other destructured fields like uri, did, etc. are not directly used here by name 115 110 }) => ({ 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(), 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(), 133 134 }), 134 135 ), 136 + // Explicitly cast to OutputSchema. Make sure OutputSchema allows undefined for these fields. 135 137 } as OutputSchema; 136 138 }