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
sneaking in a little ui
Natalie B
10 months ago
2bbb0364
b4828586
+278
-65
3 changed files
expand all
collapse all
unified
split
apps
amethyst
app
auth
login.tsx
lib
atp
pds.ts
utils.ts
+231
-30
apps/amethyst/app/auth/login.tsx
···
1
1
import { Link, Stack, router } from "expo-router";
2
2
import { AlertCircle, AtSign, Check, ChevronRight } from "lucide-react-native";
3
3
-
import React, { useState } from "react";
4
4
-
import { Platform, View } from "react-native";
3
3
+
import React, { useState, useEffect, useCallback, useRef } from "react"; // Added useCallback, useRef
4
4
+
import { Platform, TextInput, View } from "react-native";
5
5
import { SafeAreaView } from "react-native-safe-area-context";
6
6
import { Button } from "@/components/ui/button";
7
7
import { Input } from "@/components/ui/input";
8
8
import { Text } from "@/components/ui/text";
9
9
import { Icon } from "@/lib/icons/iconWithClassName";
10
10
-
import { cn } from "@/lib/utils";
10
10
+
import { capFirstLetter, cn } from "@/lib/utils";
11
11
12
12
import { openAuthSessionAsync } from "expo-web-browser";
13
13
import { useStore } from "@/stores/mainStore";
14
14
+
import { resolveFromIdentity } from "@/lib/atp/pid";
15
15
+
16
16
+
import Animated, {
17
17
+
useSharedValue,
18
18
+
useAnimatedStyle,
19
19
+
withTiming,
20
20
+
interpolate,
21
21
+
} from "react-native-reanimated";
22
22
+
import { MaterialCommunityIcons, FontAwesome6 } from "@expo/vector-icons";
23
23
+
24
24
+
type Url = URL;
25
25
+
26
26
+
interface ResolvedIdentity {
27
27
+
pds: Url;
28
28
+
[key: string]: any;
29
29
+
}
30
30
+
31
31
+
const DEBOUNCE_DELAY = 500; // 500ms debounce delay
14
32
15
33
const LoginScreen = () => {
16
34
const [handle, setHandle] = useState("");
17
35
const [err, setErr] = useState<string | undefined>();
18
36
const [isRedirecting, setIsRedirecting] = useState(false);
19
37
const [isLoading, setIsLoading] = useState(false);
38
38
+
const [isSelected, setIsSelected] = useState(false);
39
39
+
40
40
+
const [pdsUrl, setPdsUrl] = useState<Url | null>(null);
41
41
+
const [isResolvingPds, setIsResolvingPds] = useState(false);
42
42
+
const [pdsResolutionError, setPdsResolutionError] = useState<
43
43
+
string | undefined
44
44
+
>();
45
45
+
46
46
+
const handleInputRef = useRef<TextInput>(null);
20
47
21
48
const { getLoginUrl, oauthCallback } = useStore((state) => state);
22
49
50
50
+
const messageAnimation = useSharedValue(0);
51
51
+
52
52
+
// focus on load
53
53
+
useEffect(() => {
54
54
+
if (handleInputRef.current) {
55
55
+
handleInputRef.current.focus();
56
56
+
}
57
57
+
}, []);
58
58
+
59
59
+
useEffect(() => {
60
60
+
if (isResolvingPds || pdsResolutionError || pdsUrl) {
61
61
+
messageAnimation.value = withTiming(1, { duration: 500 });
62
62
+
} else {
63
63
+
messageAnimation.value = withTiming(0, { duration: 400 });
64
64
+
}
65
65
+
}, [isResolvingPds, pdsResolutionError, messageAnimation, pdsUrl]);
66
66
+
67
67
+
const messageContainerAnimatedStyle = useAnimatedStyle(() => {
68
68
+
return {
69
69
+
opacity: messageAnimation.value,
70
70
+
maxHeight: interpolate(messageAnimation.value, [0, 1], [0, 100]),
71
71
+
marginTop: -8,
72
72
+
paddingTop: 8,
73
73
+
overflow: "hidden",
74
74
+
zIndex: -1,
75
75
+
};
76
76
+
});
77
77
+
78
78
+
const getPdsUrl = useCallback(
79
79
+
async (
80
80
+
currentHandle: string,
81
81
+
callbacks?: {
82
82
+
onSuccess?: (resolvedPdsUrl: Url) => void;
83
83
+
onError?: (errorMessage: string) => void;
84
84
+
},
85
85
+
): Promise<void> => {
86
86
+
// Ensure we're not calling with an empty or whitespace-only handle due to debounce race.
87
87
+
if (!currentHandle || currentHandle.trim() === "") {
88
88
+
// Clear any potential resolving/error states if triggered by empty text
89
89
+
setPdsResolutionError(undefined);
90
90
+
setIsResolvingPds(false);
91
91
+
setPdsUrl(null);
92
92
+
callbacks?.onError?.("Handle cannot be empty for PDS resolution."); // Optional: notify caller
93
93
+
return;
94
94
+
}
95
95
+
96
96
+
setIsResolvingPds(true);
97
97
+
setPdsResolutionError(undefined);
98
98
+
setPdsUrl(null);
99
99
+
try {
100
100
+
console.log(`Attempting to resolve PDS for handle: ${currentHandle}`);
101
101
+
const identity: ResolvedIdentity | null =
102
102
+
await resolveFromIdentity(currentHandle);
103
103
+
104
104
+
if (!identity || !identity.pds) {
105
105
+
throw new Error("Could not resolve PDS from the provided handle.");
106
106
+
}
107
107
+
108
108
+
setPdsUrl(identity.pds);
109
109
+
callbacks?.onSuccess?.(identity.pds);
110
110
+
setIsResolvingPds(false);
111
111
+
} catch (e: any) {
112
112
+
const errorMessage =
113
113
+
e.message || "An unknown error occurred while resolving PDS.";
114
114
+
setPdsResolutionError(errorMessage);
115
115
+
callbacks?.onError?.(errorMessage);
116
116
+
} finally {
117
117
+
if (pdsResolutionError && isResolvingPds) {
118
118
+
setIsResolvingPds(false);
119
119
+
}
120
120
+
}
121
121
+
},
122
122
+
[isResolvingPds, pdsResolutionError],
123
123
+
);
124
124
+
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
125
125
+
126
126
+
useEffect(() => {
127
127
+
return () => {
128
128
+
if (debounceTimeoutRef.current) {
129
129
+
clearTimeout(debounceTimeoutRef.current);
130
130
+
}
131
131
+
};
132
132
+
}, []);
133
133
+
134
134
+
const handleTextChange = useCallback(
135
135
+
(text: string) => {
136
136
+
setHandle(text);
137
137
+
138
138
+
if (debounceTimeoutRef.current) {
139
139
+
clearTimeout(debounceTimeoutRef.current);
140
140
+
}
141
141
+
142
142
+
if (text.trim().length > 3) {
143
143
+
debounceTimeoutRef.current = setTimeout(() => {
144
144
+
getPdsUrl(text.trim(), {
145
145
+
onSuccess: (u) => {
146
146
+
setPdsUrl(u);
147
147
+
},
148
148
+
onError: (e) => {
149
149
+
console.error(e);
150
150
+
setPdsResolutionError("Couldn't resolve handle");
151
151
+
},
152
152
+
});
153
153
+
}, DEBOUNCE_DELAY);
154
154
+
} else {
155
155
+
setPdsResolutionError(undefined);
156
156
+
setIsResolvingPds(false);
157
157
+
setPdsUrl(null);
158
158
+
}
159
159
+
},
160
160
+
[getPdsUrl],
161
161
+
);
162
162
+
23
163
const handleLogin = async () => {
164
164
+
// reset state
165
165
+
if (debounceTimeoutRef.current) {
166
166
+
clearTimeout(debounceTimeoutRef.current);
167
167
+
}
168
168
+
setIsResolvingPds(false);
169
169
+
setPdsResolutionError(undefined);
170
170
+
24
171
if (!handle) {
25
172
setErr("Please enter a handle");
26
173
return;
27
174
}
28
175
29
176
setIsLoading(true);
177
177
+
setErr(undefined);
30
178
31
179
try {
32
180
let redirUrl = await getLoginUrl(handle.replace("@", ""));
33
181
if (!redirUrl) {
34
34
-
// TODO: better error handling lulw
35
35
-
throw new Error("Could not get login url. ");
182
182
+
throw new Error("Could not get login url.");
36
183
}
37
184
setIsRedirecting(true);
38
185
if (Platform.OS === "web") {
39
39
-
// redirect to redir url page without authsession
40
40
-
// shyould! redirect to /auth/callback
41
186
router.navigate(redirUrl.toString());
42
187
} else {
43
188
const res = await openAuthSessionAsync(
···
47
192
if (res.type === "success") {
48
193
const params = new URLSearchParams(res.url.split("?")[1]);
49
194
await oauthCallback(params);
195
195
+
} else if (res.type === "cancel" || res.type === "dismiss") {
196
196
+
setErr("Login cancelled by user.");
197
197
+
setIsRedirecting(false);
198
198
+
setIsLoading(false);
199
199
+
return;
200
200
+
} else {
201
201
+
throw new Error("Authentication failed or was cancelled.");
50
202
}
51
203
}
52
204
} catch (e: any) {
53
53
-
console.error(e);
54
54
-
setErr(e.message);
205
205
+
setErr(e.message || "An unknown error occurred during login.");
55
206
setIsLoading(false);
56
207
setIsRedirecting(false);
57
208
return;
···
67
218
headerShown: false,
68
219
}}
69
220
/>
70
70
-
<View className="justify-center align-center p-8 gap-4 pb-32 max-w-screen-sm w-screen">
221
221
+
<View className="justify-center align-center p-8 gap-4 pb-32 max-w-lg w-screen">
71
222
<View className="flex items-center">
72
223
<Icon icon={AtSign} className="color-bsky" name="at" size={64} />
73
224
</View>
···
77
228
<View>
78
229
<Text className="text-sm text-muted-foreground">Handle</Text>
79
230
<Input
80
80
-
className={err && `border-red-500 border-2`}
81
81
-
placeholder="alice.bsky.social"
231
231
+
ref={handleInputRef}
232
232
+
className={cn(
233
233
+
"ring-0",
234
234
+
(err || pdsResolutionError) && `border-red-500`,
235
235
+
)}
236
236
+
placeholder="alice.bsky.social or did:plc:..."
82
237
value={handle}
83
83
-
onChangeText={setHandle}
238
238
+
onChangeText={handleTextChange}
239
239
+
onFocus={(e) => setIsSelected(true)}
240
240
+
onBlur={(e) => setIsSelected(false)}
84
241
autoCapitalize="none"
85
242
autoCorrect={false}
86
243
onKeyPress={(e) => {
···
89
246
}
90
247
}}
91
248
/>
92
92
-
{err ? (
93
93
-
<Text className="text-red-500 justify-baseline mt-1 text-xs">
94
94
-
<Icon
95
95
-
icon={AlertCircle}
96
96
-
className="mr-1 inline -mt-0.5 text-xs"
97
97
-
size={20}
98
98
-
/>
99
99
-
{err}
100
100
-
</Text>
101
101
-
) : (
102
102
-
<View className="h-6" />
103
103
-
)}
249
249
+
250
250
+
<Animated.View style={messageContainerAnimatedStyle}>
251
251
+
<View
252
252
+
className={cn(
253
253
+
"p-2 -mt-7 rounded-xl border border-border transition-all duration-300",
254
254
+
isSelected ? "pt-9" : "pt-8",
255
255
+
pdsUrl !== null
256
256
+
? pdsUrl.hostname.includes("bsky.network")
257
257
+
? "bg-sky-400 dark:bg-sky-800"
258
258
+
: "bg-teal-400 dark:bg-teal-800"
259
259
+
: pdsResolutionError && "bg-red-300 dark:bg-red-800",
260
260
+
)}
261
261
+
>
262
262
+
{pdsUrl !== null ? (
263
263
+
<Text>
264
264
+
PDS:{" "}
265
265
+
{pdsUrl.hostname.includes("bsky.network") && (
266
266
+
<View className="gap-0.5 pr-0.5 flex-row">
267
267
+
<Icon
268
268
+
icon={FontAwesome6}
269
269
+
className="color-bsky"
270
270
+
name="bluesky"
271
271
+
size={16}
272
272
+
/>
273
273
+
<Icon
274
274
+
icon={MaterialCommunityIcons}
275
275
+
className="color-red-400"
276
276
+
name="mushroom"
277
277
+
size={18}
278
278
+
/>
279
279
+
</View>
280
280
+
)}
281
281
+
{pdsUrl.hostname.includes("bsky.network")
282
282
+
? capFirstLetter(pdsUrl.hostname.split(".").shift() || "")
283
283
+
: pdsUrl.hostname}
284
284
+
</Text>
285
285
+
) : pdsResolutionError ? (
286
286
+
<Text className="justify-baseline px-1">
287
287
+
<Icon
288
288
+
icon={AlertCircle}
289
289
+
className="mr-1 inline -mt-0.5 text-xs"
290
290
+
size={24}
291
291
+
/>
292
292
+
{pdsResolutionError}
293
293
+
</Text>
294
294
+
) : (
295
295
+
<Text className="text-muted-foreground px-1">
296
296
+
Resolving PDS...
297
297
+
</Text>
298
298
+
)}
299
299
+
</View>
300
300
+
</Animated.View>
104
301
</View>
105
302
<View className="flex flex-row justify-between items-center">
106
106
-
<Link href="https://bsky.app/signup">
107
107
-
<Text className="text-md ml-2 text-secondary">
108
108
-
Sign up for Bluesky
109
109
-
</Text>
303
303
+
<Link href="https://bsky.app/signup" asChild>
304
304
+
<Button variant="link" className="p-0">
305
305
+
<Text className="text-md text-secondary">
306
306
+
Sign up for Bluesky
307
307
+
</Text>
308
308
+
</Button>
110
309
</Link>
111
310
<Button
112
311
className={cn(
···
114
313
isRedirecting ? "bg-green-500" : "bg-bsky",
115
314
)}
116
315
onPress={handleLogin}
117
117
-
disabled={isLoading}
316
316
+
disabled={!pdsUrl}
118
317
>
119
318
{isRedirecting ? (
120
319
<>
121
320
<Text className="text-lg">Redirecting</Text>
122
321
<Icon icon={Check} />
123
322
</>
323
323
+
) : isLoading ? (
324
324
+
<Text className="text-lg">Signing in...</Text>
124
325
) : (
125
326
<>
126
327
<Text className="text-lg">Sign in</Text>
+41
-35
apps/amethyst/lib/atp/pds.ts
···
14
14
* @returns The PDS endpoint, if available
15
15
*/
16
16
export const getPdsEndpoint = (doc: DidDocument): string | undefined => {
17
17
-
return getServiceEndpoint(doc, '#atproto_pds', 'AtprotoPersonalDataServer');
17
17
+
return getServiceEndpoint(doc, "#atproto_pds", "AtprotoPersonalDataServer");
18
18
};
19
19
20
20
/**
···
25
25
* @returns The requested service endpoint, if available
26
26
*/
27
27
export const getServiceEndpoint = (
28
28
-
doc: DidDocument,
29
29
-
serviceId: string,
30
30
-
serviceType: string,
28
28
+
doc: DidDocument,
29
29
+
serviceId: string,
30
30
+
serviceType: string,
31
31
): string | undefined => {
32
32
-
const did = doc.id;
32
32
+
const did = doc.id;
33
33
34
34
-
const didServiceId = did + serviceId;
35
35
-
const found = doc.service?.find((service) => service.id === serviceId || service.id === didServiceId);
34
34
+
const didServiceId = did + serviceId;
35
35
+
const found = doc.service?.find(
36
36
+
(service) => service.id === serviceId || service.id === didServiceId,
37
37
+
);
36
38
37
37
-
if (!found || found.type !== serviceType || typeof found.serviceEndpoint !== 'string') {
38
38
-
return undefined;
39
39
-
}
39
39
+
if (
40
40
+
!found ||
41
41
+
found.type !== serviceType ||
42
42
+
typeof found.serviceEndpoint !== "string"
43
43
+
) {
44
44
+
return undefined;
45
45
+
}
40
46
41
41
-
return validateUrl(found.serviceEndpoint);
47
47
+
return validateUrl(found.serviceEndpoint);
42
48
};
43
49
const validateUrl = (urlStr: string): string | undefined => {
44
44
-
let url;
45
45
-
try {
46
46
-
url = new URL(urlStr);
47
47
-
} catch {
48
48
-
return undefined;
49
49
-
}
50
50
+
let url;
51
51
+
try {
52
52
+
url = new URL(urlStr);
53
53
+
} catch {
54
54
+
return undefined;
55
55
+
}
50
56
51
51
-
const proto = url.protocol;
57
57
+
const proto = url.protocol;
52
58
53
53
-
if (url.hostname && (proto === 'http:' || proto === 'https:')) {
54
54
-
return urlStr;
55
55
-
}
59
59
+
if (url.hostname && (proto === "http:" || proto === "https:")) {
60
60
+
return urlStr;
61
61
+
}
56
62
};
57
63
58
64
/**
59
65
* DID document
60
66
*/
61
67
export interface DidDocument {
62
62
-
id: string;
63
63
-
alsoKnownAs?: string[];
64
64
-
verificationMethod?: Array<{
65
65
-
id: string;
66
66
-
type: string;
67
67
-
controller: string;
68
68
-
publicKeyMultibase?: string;
69
69
-
}>;
70
70
-
service?: Array<{
71
71
-
id: string;
72
72
-
type: string;
73
73
-
serviceEndpoint: string | Record<string, unknown>;
74
74
-
}>;
75
75
-
}
68
68
+
id: string;
69
69
+
alsoKnownAs?: string[];
70
70
+
verificationMethod?: {
71
71
+
id: string;
72
72
+
type: string;
73
73
+
controller: string;
74
74
+
publicKeyMultibase?: string;
75
75
+
}[];
76
76
+
service?: {
77
77
+
id: string;
78
78
+
type: string;
79
79
+
serviceEndpoint: string | Record<string, unknown>;
80
80
+
}[];
81
81
+
}
+6
apps/amethyst/lib/utils.ts
···
4
4
export function cn(...inputs: ClassValue[]) {
5
5
return twMerge(clsx(inputs));
6
6
}
7
7
+
8
8
+
export function capFirstLetter(str: string) {
9
9
+
let arr = str.split("");
10
10
+
let first = arr.shift()?.toUpperCase();
11
11
+
return (first || "") + arr.join("");
12
12
+
}