···4import { Text } from "@/components/ui/text";
5import { Button } from "@/components/ui/button";
6import { Icon } from "@/lib/icons/iconWithClassName";
7-import { ArrowRight } from "lucide-react-native";
89import { Stack, router } from "expo-router";
10import { FontAwesome6 } from "@expo/vector-icons";
···34 <Text className="text-foreground text-lg">
35 No account? That's fine.
36 </Text>
37- <Text className="text-foreground mb-4 text-center text-lg">
38 Sign up for Bluesky, then return here to sign in.
00039 </Text>
40 {/* on click, open tab, then in the background navigate to /login */}
41 <Button
···4import { Text } from "@/components/ui/text";
5import { Button } from "@/components/ui/button";
6import { Icon } from "@/lib/icons/iconWithClassName";
7+import { ArrowRight, Info } from "lucide-react-native";
89import { Stack, router } from "expo-router";
10import { FontAwesome6 } from "@expo/vector-icons";
···34 <Text className="text-foreground text-lg">
35 No account? That's fine.
36 </Text>
37+ <Text className="text-foreground text-center text-lg">
38 Sign up for Bluesky, then return here to sign in.
39+ </Text>
40+ <Text className="text-muted-foreground mt-2 mb-4 text-center text-xs">
41+ You'll need a PDS to use teal.fm. Bluesky is a good way to get one.
42 </Text>
43 {/* on click, open tab, then in the background navigate to /login */}
44 <Button
+3-2
apps/amethyst/lib/atp/oauth.tsx
···12>;
1314export default function createOAuthClient(
15- baseUrl: string
016): AquareumOAuthClient {
17 if (!baseUrl) {
18 throw new Error("baseUrl is required");
···60 };
61 clientMetadataSchema.parse(meta);
62 return new ReactNativeOAuthClient({
63- handleResolver: "https://bsky.social", // backend instances should use a DNS based resolver
64 responseMode: "query", // or "fragment" (frontend only) or "form_post" (backend only)
6566 // These must be the same metadata as the one exposed on the
···12>;
1314export default function createOAuthClient(
15+ baseUrl: string,
16+ pdsBaseUrl: string,
17): AquareumOAuthClient {
18 if (!baseUrl) {
19 throw new Error("baseUrl is required");
···61 };
62 clientMetadataSchema.parse(meta);
63 return new ReactNativeOAuthClient({
64+ handleResolver: "https://" + pdsBaseUrl, // backend instances should use a DNS based resolver
65 responseMode: "query", // or "fragment" (frontend only) or "form_post" (backend only)
6667 // These must be the same metadata as the one exposed on the
+70-62
apps/amethyst/lib/atp/pid.ts
···56// resolve pid
7export const isDid = (did: string) => {
8- // is this a did? regex
9- return did.match(/^did:[a-z]+:[\S\s]+/)
10-}
1112-export const resolveHandle = async (handle: string, resolverAppViewUrl: string = "https://public.api.bsky.app"): Promise<string> => {
13- const url = resolverAppViewUrl + `/xrpc/com.atproto.identity.resolveHandle` + `?handle=${handle}`;
0000001415- const response = await fetch(url);
16- if (response.status === 400) {
17- throw new Error(`domain handle not found`);
18- } else if (!response.ok) {
19- throw new Error(`directory is unreachable`);
20- }
2122- const json = (await response.json())
23- return json.did;
24};
2526-27-28export const getDidDocument = async (did: string) => {
29- const colon_index = did.indexOf(':', 4);
3031- const type = did.slice(4, colon_index);
32- const ident = did.slice(colon_index + 1);
3334- // get a did:plc
35- if (type === 'plc') {
36- const res = await fetch("https://plc.directory/" + did)
3738- if (res.status === 400) {
39- throw new Error(`domain handle not found`);
40- } else if (!res.ok) {
41- throw new Error(`directory is unreachable`);
42- }
43-44- const json = (await res.json())
45- return json;
46- } else if (type === "web") {
47- if (ident.match(/^([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))$/)) {
48- throw new Error(`invalid domain handle`);
49- }
50- const res = await fetch(`https://${ident}/.well-known/did.json`);
5152- if (res.status === 400) {
53- throw new Error(`domain handle not found`);
54- }
55- else if (!res.ok) {
56- throw new Error(`directory is unreachable`);
57- }
0005859- const json = await res.json();
60- return json;
0061 }
62-63-0064};
6566-export const resolveFromIdentity = async(identity: string, resolverAppViewUrl: string = "https://public.api.bsky.app") => {
67- let did: string
68- // is this a did? regex
69- if (isDid(identity)) {
70- did = identity
71- } else {
72- did = await resolveHandle(identity, resolverAppViewUrl)
73- }
0007475- let doc = await getDidDocument(did)
76- let pds = getPdsEndpoint(doc)
7778- if (!pds) {
79- throw new Error("account doesn't have PDS endpoint?")
80- }
8182- return {
83- did,doc,identity,
84- pds: new URL(pds),
85- }
86-}00
···56// resolve pid
7export const isDid = (did: string) => {
8+ // is this a did? regex
9+ return did.match(/^did:[a-z]+:[\S\s]+/);
10+};
1112+export const resolveHandle = async (
13+ handle: string,
14+ resolverAppViewUrl: string = "https://public.api.bsky.app",
15+): Promise<string> => {
16+ const url =
17+ resolverAppViewUrl +
18+ `/xrpc/com.atproto.identity.resolveHandle` +
19+ `?handle=${handle}`;
2021+ const response = await fetch(url);
22+ if (response.status === 400) {
23+ throw new Error(`domain handle not found`);
24+ } else if (!response.ok) {
25+ throw new Error(`directory is unreachable`);
26+ }
2728+ const json = await response.json();
29+ return json.did;
30};
310032export const getDidDocument = async (did: string) => {
33+ const colon_index = did.indexOf(":", 4);
3435+ const type = did.slice(4, colon_index);
36+ const ident = did.slice(colon_index + 1);
3738+ // get a did:plc
39+ if (type === "plc") {
40+ const res = await fetch("https://plc.directory/" + did);
4142+ if (res.status === 400) {
43+ throw new Error(`domain handle not found`);
44+ } else if (!res.ok) {
45+ throw new Error(`directory is unreachable`);
46+ }
000000004748+ const json = await res.json();
49+ return json;
50+ } else if (type === "web") {
51+ if (
52+ !ident.match(/^([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))$/)
53+ ) {
54+ throw new Error(`invalid domain handle`);
55+ }
56+ const res = await fetch(`https://${ident}/.well-known/did.json`);
5758+ if (res.status === 400) {
59+ throw new Error(`domain handle not found`);
60+ } else if (!res.ok) {
61+ throw new Error(`directory is unreachable`);
62 }
63+64+ const json = await res.json();
65+ return json;
66+ }
67};
6869+export const resolveFromIdentity = async (
70+ identity: string,
71+ resolverAppViewUrl: string = "https://public.api.bsky.app",
72+) => {
73+ let did: string;
74+ // is this a did? regex
75+ if (isDid(identity)) {
76+ did = identity;
77+ } else {
78+ did = await resolveHandle(identity, resolverAppViewUrl);
79+ }
8081+ let doc = await getDidDocument(did);
82+ let pds = getPdsEndpoint(doc);
8384+ if (!pds) {
85+ throw new Error("account doesn't have PDS endpoint?");
86+ }
8788+ return {
89+ did,
90+ doc,
91+ identity,
92+ pds: new URL(pds),
93+ };
94+};
+16-9
apps/amethyst/stores/authenticationSlice.tsx
···1-import create from "zustand";
2import { StateCreator } from "./mainStore";
3import createOAuthClient, { AquareumOAuthClient } from "../lib/atp/oauth";
4import { OAuthSession } from "@atproto/oauth-client";
5import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
6import { Agent } from "@atproto/api";
7import * as Lexicons from "@teal/lexicons/src/lexicons";
089export interface AuthenticationSlice {
10 auth: AquareumOAuthClient;
···19 loading: boolean;
20 error: null | string;
21 };
22- pds: {
23 url: string;
24 loading: boolean;
25 error: null | string;
···37 // check if we have CF_PAGES_URL set. if not, use localhost
38 const baseUrl = process.env.EXPO_PUBLIC_BASE_URL || "http://localhost:8081";
39 console.log("Using base URL:", baseUrl);
40- const initialAuth = createOAuthClient(baseUrl);
4142 console.log("Auth client created!");
43···54 loading: false,
55 error: null,
56 },
57- pds: {
58- url: "bsky.social",
59- loading: false,
60- error: null,
61- },
6263 getLoginUrl: async (handle: string) => {
64 try {
65- const url = await initialAuth.authorize(handle);
0000000000066 return url;
67 } catch (error) {
68 console.error("Failed to get login URL:", error);
···01import { StateCreator } from "./mainStore";
2import createOAuthClient, { AquareumOAuthClient } from "../lib/atp/oauth";
3import { OAuthSession } from "@atproto/oauth-client";
4import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
5import { Agent } from "@atproto/api";
6import * as Lexicons from "@teal/lexicons/src/lexicons";
7+import { resolveFromIdentity } from "@/lib/atp/pid";
89export interface AuthenticationSlice {
10 auth: AquareumOAuthClient;
···19 loading: boolean;
20 error: null | string;
21 };
22+ pds: null | {
23 url: string;
24 loading: boolean;
25 error: null | string;
···37 // check if we have CF_PAGES_URL set. if not, use localhost
38 const baseUrl = process.env.EXPO_PUBLIC_BASE_URL || "http://localhost:8081";
39 console.log("Using base URL:", baseUrl);
40+ const initialAuth = createOAuthClient(baseUrl, "bsky.social");
4142 console.log("Auth client created!");
43···54 loading: false,
55 error: null,
56 },
57+ pds: null,
00005859 getLoginUrl: async (handle: string) => {
60 try {
61+ // resolve the handle to a PDS URL
62+ const r = resolveFromIdentity(handle);
63+ let auth = createOAuthClient(baseUrl, (await r).pds.hostname);
64+ const url = await auth.authorize(handle);
65+ set({
66+ auth,
67+ pds: {
68+ url: url.toString(),
69+ loading: false,
70+ error: null,
71+ },
72+ });
73 return url;
74 } catch (error) {
75 console.error("Failed to get login URL:", error);