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

abstracted actor view, added beginning of profile editing

+343 -52
+3 -52
apps/amethyst/app/(tabs)/index.tsx
··· 11 11 import { Button } from "@/components/ui/button"; 12 12 import { Icon } from "@/lib/icons/iconWithClassName"; 13 13 import { Plus } from "lucide-react-native"; 14 + import ActorView from "@/components/actor/actorView"; 14 15 15 16 const GITHUB_AVATAR_URI = 16 17 "https://i.pinimg.com/originals/ef/a2/8d/efa28d18a04e7fa40ed49eeb0ab660db.jpg"; ··· 26 27 } 27 28 28 29 // TODO: replace with skeleton 29 - if (!profile) { 30 + if (!profile || !agent) { 30 31 return ( 31 32 <View className="flex-1 justify-center items-center gap-5 p-6 bg-background"> 32 33 <ActivityIndicator size="large" /> ··· 43 44 headerShown: false, 44 45 }} 45 46 /> 46 - {profile.bsky?.banner && ( 47 - <Image 48 - className="w-full max-w-[100vh] h-32 md:h-44 scale-[1.32] rounded-xl -mb-6" 49 - source={{ uri: profile.bsky?.banner ?? GITHUB_AVATAR_URI }} 50 - /> 51 - )} 52 - <View className="flex flex-col items-left justify-start text-left max-w-2xl w-screen gap-1 p-4 px-8"> 53 - <View className="flex flex-row justify-between items-center"> 54 - <View className="flex justify-between"> 55 - <Avatar alt="Rick Sanchez's Avatar" className="w-24 h-24"> 56 - <AvatarImage 57 - source={{ uri: profile.bsky?.avatar ?? GITHUB_AVATAR_URI }} 58 - /> 59 - <AvatarFallback> 60 - <Text>{profile.bsky?.displayName?.substring(0, 1) ?? "R"}</Text> 61 - </AvatarFallback> 62 - </Avatar> 63 - <CardTitle className="text-left flex w-full justify-between mt-2"> 64 - {profile.bsky?.displayName ?? " Richard"} 65 - </CardTitle> 66 - </View> 67 - <View className="mt-8"> 68 - <Button 69 - variant="outline" 70 - size="sm" 71 - className="text-white rounded-xl flex flex-row gap-2 justify-center items-center" 72 - > 73 - <Icon icon={Plus} size={18} /> 74 - <Text>Follow</Text> 75 - </Button> 76 - </View> 77 - </View> 78 - <View> 79 - {profile 80 - ? profile.bsky?.description?.split("\n").map((str, i) => ( 81 - <Text 82 - className="text-start self-start place-self-start" 83 - key={i} 84 - > 85 - {str} 86 - </Text> 87 - )) || "A very mysterious person" 88 - : "Loading..."} 89 - </View> 90 - </View> 91 - <View className="max-w-2xl w-full gap-4 py-4 pl-8"> 92 - <Text className="text-left text-2xl border-b border-b-muted-foreground/30 -ml-2 pl-2 mr-6"> 93 - Your Stamps 94 - </Text> 95 - <ActorPlaysView repo={agent?.did} /> 96 - </View> 47 + <ActorView actorDid={agent.did!} pdsAgent={agent} /> 97 48 </ScrollView> 98 49 ); 99 50 }
+145
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"; 5 + import { useStore } from "@/stores/mainStore"; 6 + 7 + import ActorPlaysView from "@/components/play/actorPlaysView"; 8 + import { Button } from "@/components/ui/button"; 9 + import { Icon } from "@/lib/icons/iconWithClassName"; 10 + import { MoreHorizontal, Pen, Plus } from "lucide-react-native"; 11 + import { Agent, AppBskyActorProfile } from "@atproto/api"; 12 + import { useState, useEffect } from "react"; 13 + import { AllProfileViews } from "@/stores/authenticationSlice"; 14 + import EditProfileModal from "./editProfileView"; 15 + 16 + const GITHUB_AVATAR_URI = 17 + "https://i.pinimg.com/originals/ef/a2/8d/efa28d18a04e7fa40ed49eeb0ab660db.jpg"; 18 + 19 + export interface ActorViewProps { 20 + actorDid: string; 21 + pdsAgent: Agent; 22 + } 23 + 24 + export default function ActorView({ actorDid, pdsAgent }: ActorViewProps) { 25 + const [isEditing, setIsEditing] = useState(false); 26 + const [profile, setProfile] = useState<AllProfileViews | null>(null); 27 + const profileData = useStore((state) => state.profiles[actorDid]); 28 + 29 + useEffect(() => { 30 + if (profileData) { 31 + setProfile(profileData); 32 + } 33 + }, [profileData]); 34 + 35 + const isSelf = actorDid === pdsAgent.did; 36 + 37 + const handleSave = ( 38 + updatedProfile: { displayName: any; description: any }, 39 + newAvatarUri: string, 40 + newBannerUri: string, 41 + ) => { 42 + // Implement your save logic here (e.g., update your database or state) 43 + console.log("Saving profile:", updatedProfile, newAvatarUri, newBannerUri); 44 + 45 + // Update the local profile data 46 + setProfile( 47 + (prevProfile) => 48 + ({ 49 + ...prevProfile, 50 + bsky: { 51 + ...prevProfile?.bsky, 52 + displayName: updatedProfile.displayName, 53 + description: updatedProfile.description, 54 + avatar: newAvatarUri, 55 + banner: newBannerUri, 56 + }, 57 + }) as AllProfileViews, 58 + ); 59 + 60 + setIsEditing(false); // Close the modal after saving 61 + }; 62 + 63 + if (!profile) { 64 + return null; 65 + } 66 + 67 + return ( 68 + <> 69 + {profile.bsky?.banner && ( 70 + <Image 71 + className="w-full max-w-[100vh] h-32 md:h-44 scale-[1.32] rounded-xl -mb-6" 72 + source={{ uri: profile.bsky?.banner ?? GITHUB_AVATAR_URI }} 73 + /> 74 + )} 75 + <View className="flex flex-col items-left justify-start text-left max-w-2xl w-screen gap-1 p-4 px-8"> 76 + <View className="flex flex-row justify-between items-center"> 77 + <View className="flex justify-between"> 78 + <Avatar alt="Rick Sanchez's Avatar" className="w-24 h-24"> 79 + <AvatarImage 80 + source={{ uri: profile.bsky?.avatar ?? GITHUB_AVATAR_URI }} 81 + /> 82 + <AvatarFallback> 83 + <Text>{profile.bsky?.displayName?.substring(0, 1) ?? "R"}</Text> 84 + </AvatarFallback> 85 + </Avatar> 86 + <CardTitle className="text-left flex w-full justify-between mt-2"> 87 + {profile.bsky?.displayName ?? " Richard"} 88 + </CardTitle> 89 + </View> 90 + <View className="mt-2 flex-row gap-2"> 91 + {isSelf ? ( 92 + <Button 93 + variant="outline" 94 + size="sm" 95 + className="rounded-xl flex-row gap-2 justify-center items-center" 96 + onPress={() => setIsEditing(true)} 97 + > 98 + <Icon icon={Pen} size={18} /> 99 + <Text>Edit</Text> 100 + </Button> 101 + ) : ( 102 + <Button variant="outline" size="sm" className=""> 103 + <Icon icon={Plus} size={18} /> 104 + <Text>Follow</Text> 105 + </Button> 106 + )} 107 + <Button 108 + variant="outline" 109 + size="sm" 110 + className="text-white aspect-square p-0 rounded-full flex flex-row gap-2 justify-center items-center" 111 + > 112 + <Icon icon={MoreHorizontal} size={18} /> 113 + </Button> 114 + </View> 115 + </View> 116 + <View> 117 + {profile 118 + ? profile.bsky?.description?.split("\n").map((str, i) => ( 119 + <Text 120 + className="text-start self-start place-self-start" 121 + key={i} 122 + > 123 + {str} 124 + </Text> 125 + )) || "A very mysterious person" 126 + : "Loading..."} 127 + </View> 128 + </View> 129 + <View className="max-w-2xl w-full gap-4 py-4 pl-8"> 130 + <Text className="text-left text-2xl border-b border-b-muted-foreground/30 -ml-2 pl-2 mr-6"> 131 + Your Stamps 132 + </Text> 133 + <ActorPlaysView repo={actorDid} /> 134 + </View> 135 + {isSelf && ( 136 + <EditProfileModal 137 + isVisible={isEditing} 138 + onClose={() => setIsEditing(false)} 139 + profile={profileData} // Pass the profile data 140 + onSave={handleSave} // Pass the save handler 141 + /> 142 + )} 143 + </> 144 + ); 145 + }
+151
apps/amethyst/components/actor/editProfileView.tsx
··· 1 + import * as React from "react"; 2 + import { useState } from "react"; 3 + import { 4 + Modal, 5 + Pressable, 6 + View, 7 + Image, 8 + ActivityIndicator, 9 + Touchable, 10 + TouchableWithoutFeedback, 11 + } from "react-native"; 12 + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 13 + import { Text } from "@/components/ui/text"; 14 + import { Button } from "@/components/ui/button"; 15 + import * as ImagePicker from "expo-image-picker"; 16 + import { Card } from "../ui/card"; 17 + import { Input } from "../ui/input"; 18 + import { Textarea } from "../ui/textarea"; 19 + import { cn } from "@/lib/utils"; 20 + import { useOnEscape } from "@/hooks/useOnEscape"; 21 + 22 + const GITHUB_AVATAR_URI = 23 + "https://i.pinimg.com/originals/ef/a2/8d/efa28d18a04e7fa40ed49eeb0ab660db.jpg"; 24 + 25 + export interface EditProfileModalProps { 26 + isVisible: boolean; 27 + onClose: () => void; 28 + profile: any; // Pass the profile data as a prop 29 + onSave: (profile: any, avatarUri: string, bannerUri: string) => void; // Pass the onSave callback function 30 + } 31 + 32 + export default function EditProfileModal({ 33 + isVisible, 34 + onClose, 35 + profile, // Pass the profile data as a prop 36 + onSave, // Pass the onSave callback function 37 + }: EditProfileModalProps) { 38 + const [editedProfile, setEditedProfile] = useState({ ...profile?.bsky }); 39 + const [avatarUri, setAvatarUri] = useState(profile?.bsky?.avatar); 40 + const [bannerUri, setBannerUri] = useState(profile?.bsky?.banner); 41 + const [loading, setLoading] = useState(false); 42 + 43 + const pickImage = async ( 44 + setType: typeof setAvatarUri | typeof setBannerUri, 45 + ) => { 46 + setLoading(true); // Start loading 47 + 48 + let result = await ImagePicker.launchImageLibraryAsync({ 49 + mediaTypes: ["images"], 50 + allowsEditing: true, 51 + aspect: setType === setAvatarUri ? [1, 1] : [3, 1], 52 + quality: 1, 53 + }); 54 + 55 + if (!result.canceled) { 56 + setType(result.assets[0].uri); 57 + } 58 + 59 + setLoading(false); // Stop loading 60 + }; 61 + 62 + const handleSave = () => { 63 + onSave(editedProfile, avatarUri, bannerUri); // Call the onSave callback with updated data 64 + onClose(); 65 + }; 66 + 67 + useOnEscape(onClose); 68 + 69 + if (!profile) { 70 + return ( 71 + <View className="flex-1 justify-center items-center gap-5 p-6 bg-background"> 72 + <ActivityIndicator size="large" /> 73 + </View> 74 + ); 75 + } 76 + 77 + return ( 78 + <Modal animationType="fade" transparent={true} visible={isVisible}> 79 + <TouchableWithoutFeedback onPress={() => onClose()}> 80 + <View className="flex-1 justify-center items-center bg-black/50 backdrop-blur"> 81 + <TouchableWithoutFeedback> 82 + <Card className="bg-card rounded-lg p-4 w-11/12 max-w-md"> 83 + <Text className="text-xl font-bold mb-4">Edit Profile</Text> 84 + <Pressable onPress={() => pickImage(setBannerUri)}> 85 + {loading && !bannerUri && <ActivityIndicator />} 86 + {bannerUri && ( 87 + <Image 88 + source={{ uri: bannerUri }} 89 + className="w-full h-24 rounded-lg" 90 + /> 91 + )} 92 + </Pressable> 93 + 94 + <Pressable 95 + onPress={() => pickImage(setAvatarUri)} 96 + className={cn("mb-4", bannerUri && "pl-4 -mt-8")} 97 + > 98 + {loading && !avatarUri && <ActivityIndicator />} 99 + <Avatar 100 + className="w-20 h-20" 101 + alt={`Avatar for ${editedProfile?.displayName ?? "User"}`} 102 + > 103 + <AvatarImage 104 + source={{ uri: avatarUri || GITHUB_AVATAR_URI }} 105 + /> 106 + <AvatarFallback> 107 + <Text> 108 + {editedProfile?.displayName?.substring(0, 1) ?? "R"} 109 + </Text> 110 + </AvatarFallback> 111 + </Avatar> 112 + </Pressable> 113 + 114 + <Text className="text-sm font-semibold text-muted-foreground pl-1"> 115 + Display Name 116 + </Text> 117 + <Input 118 + className="border border-gray-300 rounded px-3 py-2 mb-4" 119 + placeholder="Display Name" 120 + value={editedProfile.displayName} 121 + onChangeText={(text) => 122 + setEditedProfile({ ...editedProfile, displayName: text }) 123 + } 124 + /> 125 + <Text className="text-sm font-semibold text-muted-foreground pl-1"> 126 + Description 127 + </Text> 128 + <Textarea 129 + className="border border-gray-300 rounded px-3 py-2 mb-4" 130 + placeholder="Description" 131 + multiline 132 + value={editedProfile.description} 133 + onChangeText={(text) => 134 + setEditedProfile({ ...editedProfile, description: text }) 135 + } 136 + /> 137 + <View className="flex-row justify-between"> 138 + <Button variant="outline" onPress={onClose}> 139 + <Text>Cancel</Text> 140 + </Button> 141 + <Button onPress={handleSave}> 142 + <Text>Save</Text> 143 + </Button> 144 + </View> 145 + </Card> 146 + </TouchableWithoutFeedback> 147 + </View> 148 + </TouchableWithoutFeedback> 149 + </Modal> 150 + ); 151 + }
+27
apps/amethyst/components/ui/textarea.tsx
··· 1 + import * as React from 'react'; 2 + import { TextInput, type TextInputProps } from 'react-native'; 3 + import { cn } from '~/lib/utils'; 4 + 5 + const Textarea = React.forwardRef<React.ElementRef<typeof TextInput>, TextInputProps>( 6 + ({ className, multiline = true, numberOfLines = 4, placeholderClassName, ...props }, ref) => { 7 + return ( 8 + <TextInput 9 + ref={ref} 10 + className={cn( 11 + 'web:flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base lg:text-sm native:text-lg native:leading-[1.25] text-foreground web:ring-offset-background placeholder:text-muted-foreground web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2', 12 + props.editable === false && 'opacity-50 web:cursor-not-allowed', 13 + className 14 + )} 15 + placeholderClassName={cn('text-muted-foreground', placeholderClassName)} 16 + multiline={multiline} 17 + numberOfLines={numberOfLines} 18 + textAlignVertical='top' 19 + {...props} 20 + /> 21 + ); 22 + } 23 + ); 24 + 25 + Textarea.displayName = 'Textarea'; 26 + 27 + export { Textarea };
+17
apps/amethyst/hooks/useOnEscape.tsx
··· 1 + import { useEffect } from "react"; 2 + 3 + export const useOnEscape = (callback: () => void) => { 4 + useEffect(() => { 5 + const handleKeyDown = (event: KeyboardEvent) => { 6 + if (event.key === "Escape") { 7 + callback(); 8 + } 9 + }; 10 + 11 + document.addEventListener("keydown", handleKeyDown); 12 + 13 + return () => { 14 + document.removeEventListener("keydown", handleKeyDown); 15 + }; 16 + }, [callback]); 17 + };