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