Hopefully feature-complete Android Bluesky client written in Expo
atproto bluesky
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Copy OAuth sign in and provider from the cookbook

SharpMars 2264023a 6c6c9449

+307 -12
+4 -3
app.json
··· 5 5 "version": "1.0.0", 6 6 "orientation": "portrait", 7 7 "icon": "./assets/images/icon.png", 8 - "scheme": "dusksky", 8 + "scheme": "net.sharpmars.dusksky", 9 9 "userInterfaceStyle": "automatic", 10 10 "newArchEnabled": true, 11 11 "ios": { ··· 20 20 }, 21 21 "edgeToEdgeEnabled": true, 22 22 "predictiveBackGestureEnabled": false, 23 - "package": "com.anonymous.dusksky" 23 + "package": "net.sharpmars.dusksky" 24 24 }, 25 25 "web": { 26 26 "output": "static", ··· 40 40 } 41 41 } 42 42 ], 43 - "expo-video" 43 + "expo-video", 44 + "expo-secure-store" 44 45 ], 45 46 "experiments": { 46 47 "typedRoutes": true,
+6
bun.lock
··· 19 19 "@tanstack/react-query": "^5.90.12", 20 20 "expo": "~54.0.30", 21 21 "expo-constants": "~18.0.12", 22 + "expo-crypto": "~15.0.8", 22 23 "expo-dev-client": "~6.0.20", 23 24 "expo-font": "~14.0.10", 24 25 "expo-haptics": "~15.0.8", 25 26 "expo-image": "~3.0.11", 26 27 "expo-linking": "~8.0.11", 27 28 "expo-router": "~6.0.21", 29 + "expo-secure-store": "~15.0.8", 28 30 "expo-splash-screen": "~31.0.13", 29 31 "expo-status-bar": "~3.0.9", 30 32 "expo-symbols": "~1.0.8", ··· 949 951 950 952 "expo-constants": ["expo-constants@18.0.12", "", { "dependencies": { "@expo/config": "~12.0.12", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-WzcKYMVNRRu4NcSzfIVRD5aUQFnSpTZgXFrlWmm19xJoDa4S3/PQNi6PNTBRc49xz9h8FT7HMxRKaC8lr0gflA=="], 951 953 954 + "expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="], 955 + 952 956 "expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="], 953 957 954 958 "expo-dev-launcher": ["expo-dev-launcher@6.0.20", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.0.18", "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA=="], ··· 978 982 "expo-modules-core": ["expo-modules-core@3.0.29", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q=="], 979 983 980 984 "expo-router": ["expo-router@6.0.21", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.8", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-server": "^1.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.12", "expo-linking": "^8.0.11", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.3 || ~19.1.4 || ~19.2.3" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-wjTUjrnWj6gRYjaYl1kYfcRnNE4ZAQ0kz0+sQf6/mzBd/OU6pnOdD7WrdAW3pTTpm52Q8sMoeX98tNQEddg2uA=="], 985 + 986 + "expo-secure-store": ["expo-secure-store@15.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw=="], 981 987 982 988 "expo-server": ["expo-server@1.0.5", "", {}, "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA=="], 983 989
+25
index.js
··· 1 1 import "@formatjs/intl-segmenter/polyfill.js"; 2 + import { CryptoDigestAlgorithm, digest } from "expo-crypto"; 3 + globalThis.crypto = { 4 + subtle: { 5 + digest: (algorithm, data) => { 6 + let digestAlgo; 7 + switch (algorithm) { 8 + case "SHA-1": 9 + digestAlgo = CryptoDigestAlgorithm.SHA1; 10 + break; 11 + case "SHA-256": 12 + digestAlgo = CryptoDigestAlgorithm.SHA256; 13 + break; 14 + case "SHA-384": 15 + digestAlgo = CryptoDigestAlgorithm.SHA384; 16 + break; 17 + case "SHA-512": 18 + digestAlgo = CryptoDigestAlgorithm.SHA512; 19 + break; 20 + } 21 + 22 + return digest(digestAlgo, data); 23 + }, 24 + }, 25 + }; 26 + 2 27 import "expo-router/entry";
+2
package.json
··· 25 25 "@tanstack/react-query": "^5.90.12", 26 26 "expo": "~54.0.30", 27 27 "expo-constants": "~18.0.12", 28 + "expo-crypto": "~15.0.8", 28 29 "expo-dev-client": "~6.0.20", 29 30 "expo-font": "~14.0.10", 30 31 "expo-haptics": "~15.0.8", 31 32 "expo-image": "~3.0.11", 32 33 "expo-linking": "~8.0.11", 33 34 "expo-router": "~6.0.21", 35 + "expo-secure-store": "~15.0.8", 34 36 "expo-splash-screen": "~31.0.13", 35 37 "expo-status-bar": "~3.0.9", 36 38 "expo-symbols": "~1.0.8",
+30 -9
src/app/_layout.tsx
··· 1 - import { Stack } from "expo-router"; 1 + import { SplashScreen, Stack } from "expo-router"; 2 2 import { StatusBar } from "expo-status-bar"; 3 3 import "react-native-reanimated"; 4 4 import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 5 import { ExpoOAuthClient, ExpoOAuthClientInterface, ExpoOAuthClientOptions } from "@atproto/oauth-client-expo"; 6 + import { SessionProvider, useSession } from "../components/SessionProvider"; 6 7 7 8 export const unstable_settings = { 8 9 anchor: "(tabs)", ··· 12 13 const oauthOps: ExpoOAuthClientOptions = { 13 14 handleResolver: "https://slingshot.microcosm.blue", 14 15 clientMetadata: { 15 - client_id: "https://example.com/oauth-client-metadata.json", 16 + client_id: "https://example.com/client-metadata.json", 16 17 client_name: "React Native OAuth Client Demo", 17 18 client_uri: "https://example.com", 18 - redirect_uris: ["com.example:/auth/callback"], 19 + redirect_uris: ["net.sharpmars.dusksky:/auth/callback"], 19 20 scope: "atproto repo:* rpc:*?aud=did:web:api.bsky.app#bsky_appview", 20 21 token_endpoint_auth_method: "none", 21 22 response_types: ["code"], ··· 28 29 29 30 export default function RootLayout() { 30 31 return ( 31 - <QueryClientProvider client={queryClient}> 32 - <Stack> 33 - <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> 34 - </Stack> 35 - <StatusBar style="auto" /> 36 - </QueryClientProvider> 32 + <SessionProvider client={oauth}> 33 + <QueryClientProvider client={queryClient}> 34 + <RootNavigator /> 35 + <StatusBar style="auto" /> 36 + </QueryClientProvider> 37 + </SessionProvider> 38 + ); 39 + } 40 + 41 + function RootNavigator() { 42 + const { isLoading, isLoggedIn } = useSession(); 43 + 44 + if (!isLoading) { 45 + SplashScreen.hideAsync(); 46 + } 47 + 48 + return ( 49 + <Stack screenOptions={{ headerShown: false }}> 50 + <Stack.Protected guard={isLoggedIn}> 51 + <Stack.Screen name="(tabs)" /> 52 + </Stack.Protected> 53 + 54 + <Stack.Protected guard={!isLoggedIn}> 55 + <Stack.Screen name="sign-in" /> 56 + </Stack.Protected> 57 + </Stack> 37 58 ); 38 59 }
+84
src/app/sign-in.tsx
··· 1 + import { useSession } from "@/src/components/SessionProvider"; 2 + import { useCallback, useEffect, useState } from "react"; 3 + import { View, Text, TextInput, Button } from "react-native"; 4 + 5 + export default function SignIn() { 6 + const { isLoading, isLoggedIn, signIn } = useSession(); 7 + 8 + const disabled = isLoading || isLoggedIn; 9 + 10 + const [input, setInput] = useState<string>(""); 11 + const [error, setError] = useState<string | null>(null); 12 + 13 + useEffect(() => setError(null), [input]); 14 + 15 + const fixedInput = fixInput(input); 16 + 17 + const doSignIn = useCallback( 18 + async (input: string | null) => { 19 + setError(null); 20 + if (disabled || !signIn || !input) return; 21 + try { 22 + await signIn(input); 23 + } catch (err) { 24 + setError(err instanceof Error ? err.message : "Unknown error"); 25 + } 26 + }, 27 + [disabled, signIn] 28 + ); 29 + 30 + return ( 31 + <View 32 + style={{ 33 + flex: 1, 34 + gap: 10, 35 + padding: 10, 36 + justifyContent: "center", 37 + alignItems: "stretch", 38 + alignSelf: "center", 39 + maxWidth: 400, 40 + }} 41 + > 42 + <Text style={{ fontWeight: "bold", fontSize: 18 }}>Login with the Atmosphere</Text> 43 + <Text>Enter your handle to continue</Text> 44 + <View style={{ flexDirection: "row", alignItems: "center" }}> 45 + <TextInput 46 + style={{ 47 + borderWidth: 1, 48 + borderColor: "#ccc", 49 + padding: 7, 50 + marginRight: 5, 51 + minWidth: 200, 52 + flexGrow: 1, 53 + flexShrink: 1, 54 + flexBasis: 1, 55 + }} 56 + value={input} 57 + onChangeText={setInput} 58 + autoCapitalize="none" 59 + autoCorrect={false} 60 + autoFocus 61 + editable={!disabled} 62 + placeholder="@alice.example.com" 63 + submitBehavior="blurAndSubmit" 64 + onSubmitEditing={() => doSignIn(fixedInput)} 65 + /> 66 + <Button title="Login" disabled={disabled} onPress={() => doSignIn(fixedInput)} /> 67 + </View> 68 + <Text>If you&apos;re a Bluesky user, you already have an Atmosphere account.</Text> 69 + <Button 70 + title="Create account with Bluesky Social" 71 + disabled={disabled} 72 + onPress={() => doSignIn("https://bsky.social")} 73 + /> 74 + {error ? <Text style={{ color: "red" }}>{error}</Text> : null} 75 + </View> 76 + ); 77 + } 78 + 79 + function fixInput(input: string) { 80 + const trimmed = input.replaceAll(" ", ""); 81 + if (trimmed.length < 3) return null; // definitely invalid 82 + if (!trimmed.includes(".")) return null; // definitely invalid 83 + return trimmed; 84 + }
+156
src/components/SessionProvider.tsx
··· 1 + import type { ExpoOAuthClientInterface, OAuthSession } from "@atproto/oauth-client-expo"; 2 + import * as store from "expo-secure-store"; 3 + import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useState } from "react"; 4 + 5 + const CURRENT_AUTH_DID = "oauth_provider-current"; 6 + 7 + const SessionContext = createContext<{ 8 + session: null | OAuthSession; 9 + isLoading: boolean; 10 + isLoggedIn: boolean; 11 + signIn: (input: string) => Promise<void>; 12 + signOut: () => Promise<void>; 13 + }>({ 14 + session: null, 15 + isLoading: false, 16 + isLoggedIn: false, 17 + signIn: async () => { 18 + throw new Error("AuthContext not initialized"); 19 + }, 20 + signOut: async () => { 21 + throw new Error("AuthContext not initialized"); 22 + }, 23 + }); 24 + 25 + export function SessionProvider({ 26 + client, 27 + children, 28 + }: PropsWithChildren<{ 29 + client: ExpoOAuthClientInterface; 30 + }>) { 31 + const [initialized, setInitialized] = useState(false); 32 + const [loading, setLoading] = useState(true); 33 + const [session, setSession] = useState<null | OAuthSession>(null); 34 + 35 + // Initialize by restoring the previously loaded session, if any. 36 + useEffect(() => { 37 + setInitialized(false); 38 + setSession(null); 39 + 40 + void client 41 + .handleCallback() 42 + .then(async (newSession) => { 43 + if (newSession) return setSession(newSession); 44 + 45 + const lastDid = await store.getItemAsync(CURRENT_AUTH_DID); 46 + if (!lastDid) return; 47 + 48 + // Use "false" as restore argument to allow the app to work off-line 49 + const restoredSession = await client.restore(lastDid, false); 50 + setSession(restoredSession); 51 + 52 + // Force a refresh here, which will cause the session to be deleted 53 + // by the "deleted" event handler if the refresh token was revoked 54 + await restoredSession.getTokenInfo(true); 55 + }) 56 + .catch((err) => { 57 + console.warn("Error setting up OAuth Session", err); 58 + }) 59 + .finally(() => { 60 + setInitialized(true); 61 + setLoading(false); 62 + }); 63 + }, [client]); 64 + 65 + // If the current session gets deleted (e.g. from another browser tab, or 66 + // because a refresh token was revoked), clear it 67 + useEffect(() => { 68 + if (!session) return; 69 + 70 + const handleDelete = (event: CustomEvent<{ sub: string }>) => { 71 + if (event.detail.sub === session.did) { 72 + setSession(null); 73 + void store.deleteItemAsync(CURRENT_AUTH_DID); 74 + } 75 + }; 76 + 77 + client.addEventListener("deleted", handleDelete); 78 + return () => { 79 + client.removeEventListener("deleted", handleDelete); 80 + }; 81 + }, [client, session]); 82 + 83 + // When initializing the AuthProvider, we used "false" as restore's refresh 84 + // argument so that the app can work off-line. The following effect will 85 + // ensure that the session is pro actively refreshed whenever the app gets 86 + // back online. 87 + useEffect(() => { 88 + if (!session) return; 89 + 90 + // @NOTE If the refresh token was revoked, the "deleted" event will be 91 + // triggered on the client, causing the previous effect to clear the session 92 + const check = () => { 93 + void session.getTokenInfo(true).catch((err) => { 94 + console.warn("Failed to refresh token", err); 95 + }); 96 + }; 97 + 98 + const interval = setInterval(check, 10 * 60e3); 99 + return () => clearInterval(interval); 100 + }, [session]); 101 + 102 + const signIn = useCallback( 103 + async (input: string) => { 104 + setLoading(true); 105 + 106 + try { 107 + const session = await client.restore(input, true).catch(async (_err) => client.signIn(input)); 108 + 109 + setSession(session); 110 + await store.setItemAsync(CURRENT_AUTH_DID, session.did); 111 + } finally { 112 + setLoading(false); 113 + } 114 + }, 115 + [client] 116 + ); 117 + 118 + const signOut = useCallback(async () => { 119 + if (session) { 120 + setSession(null); 121 + setLoading(true); 122 + try { 123 + await session.signOut(); 124 + } finally { 125 + setLoading(false); 126 + } 127 + await store.deleteItemAsync(CURRENT_AUTH_DID); 128 + } 129 + }, [session]); 130 + 131 + return ( 132 + <SessionContext.Provider 133 + value={{ 134 + session, 135 + 136 + isLoading: !initialized || loading, 137 + isLoggedIn: !!session, 138 + 139 + signIn, 140 + signOut, 141 + }} 142 + > 143 + {children} 144 + </SessionContext.Provider> 145 + ); 146 + } 147 + 148 + export function useSession() { 149 + return useContext(SessionContext); 150 + } 151 + 152 + export function useOAuthSession(): OAuthSession { 153 + const { session } = useSession(); 154 + if (!session) throw new Error("User is not logged in"); 155 + return session; 156 + }