├── .nvmrc ├── .npmrc ├── .prettierignore ├── README.md ├── pnpm-workspace.yaml ├── assets ├── images │ ├── demesne.png │ ├── favicon.png │ └── demesne-nobg.png └── fonts │ └── SpaceMono-Regular.ttf ├── src ├── lib │ ├── versions.ts │ ├── queries.ts │ ├── action-sheet.ts │ ├── agent.ts │ └── accounts.tsx ├── components │ ├── header.ts │ ├── text-field.tsx │ ├── empty-state.tsx │ ├── button.tsx │ ├── header-buttons.tsx │ └── views.tsx └── app │ ├── account │ └── [did] │ │ ├── keys │ │ ├── _layout.tsx │ │ ├── add.tsx │ │ └── index.tsx │ │ ├── backup.tsx │ │ └── index.tsx │ ├── _layout.tsx │ ├── index.tsx │ └── login.tsx ├── tsconfig.json ├── eslint.config.js ├── .prettierrc.json ├── eas.json ├── .gitignore ├── app.config.ts └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | node-linker=hoisted 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Demesne 2 | 3 | PLC key management app for iOS. very much WIP! 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "*" 3 | onlyBuiltDependencies: 4 | - unrs-resolver 5 | -------------------------------------------------------------------------------- /assets/images/demesne.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/demesne/HEAD/assets/images/demesne.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/demesne/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/demesne-nobg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/demesne/HEAD/assets/images/demesne-nobg.png -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozzius/demesne/HEAD/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /src/lib/versions.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native" 2 | 3 | const iOSMajorVersion = 4 | Platform.OS === "ios" ? Number(Platform.Version.split(".")[0]) : 0 5 | 6 | export const isIOS26 = iOSMajorVersion >= 26 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "#/*": ["./src/*"] 7 | } 8 | }, 9 | "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // https://docs.expo.dev/guides/using-eslint/ 2 | const { defineConfig } = require('eslint/config'); 3 | const expoConfig = require("eslint-config-expo/flat"); 4 | 5 | module.exports = defineConfig([ 6 | expoConfig, 7 | { 8 | ignores: ["dist/*"], 9 | } 10 | ]); 11 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "plugins": ["@ianvs/prettier-plugin-sort-imports"], 4 | "importOrder": [ 5 | "", 6 | "^react", 7 | "^react-native", 8 | "^expo-(.*)", 9 | "", 10 | "", 11 | "^#/(.*)$", 12 | "", 13 | "^[./]" 14 | ], 15 | "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"], 16 | "importOrderTypeScriptVersion": "5.3.3", 17 | "importOrderCaseSensitive": false 18 | } 19 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 16.0.1", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "development": { 8 | "developmentClient": true, 9 | "distribution": "internal" 10 | }, 11 | "preview": { 12 | "distribution": "internal" 13 | }, 14 | "production": { 15 | "autoIncrement": true 16 | } 17 | }, 18 | "submit": { 19 | "production": { 20 | "ios": { 21 | "ascAppId": "6743711061" 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | .env 39 | 40 | ios 41 | android 42 | -------------------------------------------------------------------------------- /src/components/header.ts: -------------------------------------------------------------------------------- 1 | import { isIOS26 } from "#/lib/versions" 2 | 3 | export const coolTitleEffect = isIOS26 4 | ? { 5 | headerTransparent: true, 6 | } 7 | : ({ 8 | headerShadowVisible: true, 9 | headerTransparent: true, 10 | headerBlurEffect: "systemChromeMaterial", 11 | headerLargeStyle: { 12 | backgroundColor: "transparent", 13 | }, 14 | } as const) 15 | 16 | export const coolLargeTitleEffect = isIOS26 17 | ? { 18 | headerLargeTitle: true, 19 | headerTransparent: true, 20 | } 21 | : ({ 22 | headerLargeTitle: true, 23 | headerShadowVisible: true, 24 | headerLargeTitleShadowVisible: false, 25 | headerTransparent: true, 26 | headerBlurEffect: "systemChromeMaterial", 27 | headerLargeStyle: { 28 | backgroundColor: "transparent", 29 | }, 30 | } as const) 31 | -------------------------------------------------------------------------------- /src/app/account/[did]/keys/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native" 2 | import { SystemBars } from "react-native-edge-to-edge" 3 | import { Stack } from "expo-router" 4 | 5 | import { coolTitleEffect } from "#/components/header" 6 | import { isIOS26 } from "#/lib/versions" 7 | 8 | export default function KeysLayout() { 9 | return ( 10 | <> 11 | {Platform.OS === "ios" && } 12 | 13 | 19 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/components/text-field.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StyleSheet, 3 | TextInput, 4 | TextInputProps, 5 | useColorScheme, 6 | View, 7 | ViewProps, 8 | } from "react-native" 9 | import { useTheme } from "@react-navigation/native" 10 | 11 | import { useTextColor } from "./views" 12 | 13 | export function TextField({ 14 | style, 15 | ...props 16 | }: TextInputProps & { ref?: React.Ref }) { 17 | const scheme = useColorScheme() || "default" 18 | const theme = useTheme() 19 | const primary = useTextColor("primary") 20 | const tertiary = useTextColor("tertiary") 21 | 22 | return ( 23 | 37 | ) 38 | } 39 | 40 | export function InputGroup({ style, ...props }: ViewProps) { 41 | return 42 | } 43 | 44 | const styles = StyleSheet.create({ 45 | inputGroup: { 46 | gap: 4, 47 | borderRadius: 12, 48 | borderCurve: "continuous", 49 | overflow: "hidden", 50 | }, 51 | textInput: { 52 | fontSize: 16, 53 | padding: 14, 54 | borderRadius: 4, 55 | borderCurve: "continuous", 56 | }, 57 | }) 58 | -------------------------------------------------------------------------------- /src/components/empty-state.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, View } from "react-native" 2 | import { type LucideIcon } from "lucide-react-native" 3 | 4 | import { Text, useTextColor } from "#/components/views" 5 | 6 | export function EmptyState({ 7 | icon: Icon, 8 | text = "Nothing here", 9 | subText, 10 | children, 11 | }: { 12 | icon: LucideIcon 13 | text: string 14 | subText?: string 15 | children?: React.ReactNode 16 | }) { 17 | const color = useTextColor("tertiary") 18 | return ( 19 | 20 | 21 | 22 | 23 | {text} 24 | 25 | {subText && ( 26 | 27 | {subText} 28 | 29 | )} 30 | 31 | {children} 32 | 33 | ) 34 | } 35 | 36 | const styles = StyleSheet.create({ 37 | container: { 38 | flex: 1, 39 | justifyContent: "center", 40 | alignItems: "center", 41 | gap: 24, 42 | paddingTop: "30%", 43 | }, 44 | textContainer: { 45 | maxWidth: 300, 46 | textAlign: "center", 47 | gap: 8, 48 | paddingHorizontal: 16, 49 | }, 50 | text: { 51 | fontSize: 20, 52 | fontWeight: "bold", 53 | maxWidth: 300, 54 | textAlign: "center", 55 | }, 56 | subText: { 57 | fontSize: 16, 58 | maxWidth: 300, 59 | textAlign: "center", 60 | }, 61 | }) 62 | -------------------------------------------------------------------------------- /src/lib/queries.ts: -------------------------------------------------------------------------------- 1 | import { AppBskyActorGetProfiles } from "@atproto/api" 2 | import { 3 | keepPreviousData, 4 | useQuery, 5 | useQueryClient, 6 | } from "@tanstack/react-query" 7 | 8 | import { useAccounts } from "./accounts" 9 | import { publicAgent } from "./agent" 10 | 11 | export function useAccountProfilesQuery() { 12 | const accounts = useAccounts() 13 | 14 | const dids = accounts?.map((x) => x.did) ?? [] 15 | return useQuery({ 16 | queryKey: ["get-profiles", dids], 17 | queryFn: async () => { 18 | const res = await publicAgent.getProfiles({ actors: dids }) 19 | return res.data 20 | }, 21 | placeholderData: keepPreviousData, 22 | }) 23 | } 24 | 25 | export function useProfileQuery(actor: string) { 26 | const queryClient = useQueryClient() 27 | return useQuery({ 28 | queryKey: ["get-profile", actor], 29 | queryFn: async () => { 30 | const res = await publicAgent.getProfile({ actor }) 31 | return res.data 32 | }, 33 | initialData: () => { 34 | const allProfiles = 35 | queryClient.getQueriesData({ 36 | queryKey: ["get-profiles"], 37 | }) 38 | for (const [, query] of allProfiles) { 39 | if (!query) continue 40 | for (const account of query.profiles) { 41 | // I guess actor could be handle or did? consider locking down to did 42 | if (account.did === actor || account.handle === actor) { 43 | return account 44 | } 45 | } 46 | } 47 | }, 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/action-sheet.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react" 2 | import { ActionSheetIOS, findNodeHandle, View } from "react-native" 3 | 4 | type Option = { 5 | item: string 6 | destructive?: boolean 7 | disabled?: boolean 8 | } 9 | 10 | export function useActionSheet() { 11 | const ref = useRef(null) 12 | 13 | const show = useCallback( 14 | (args) => 15 | showActionSheet({ 16 | anchor: findNodeHandle(ref.current ?? null) ?? undefined, 17 | ...args, 18 | }), 19 | [ref], 20 | ) 21 | 22 | return [ref, show] as const 23 | } 24 | 25 | export function showActionSheet({ 26 | options, 27 | title, 28 | message, 29 | includeCancel = true, 30 | anchor, 31 | }: { 32 | title?: string 33 | message?: string 34 | options: T[] 35 | includeCancel?: boolean 36 | anchor?: number 37 | }): Promise { 38 | const items = options.map((op) => op.item) 39 | let cancelButtonIndex: number | undefined 40 | if (includeCancel) { 41 | cancelButtonIndex = options.length 42 | items.push("Cancel") 43 | } 44 | return new Promise((resolve) => 45 | ActionSheetIOS.showActionSheetWithOptions( 46 | { 47 | anchor, 48 | title, 49 | message, 50 | options: items, 51 | cancelButtonIndex, 52 | destructiveButtonIndex: options 53 | .map((item, i) => (item.destructive ? i : null)) 54 | .filter((op) => op !== null), 55 | disabledButtonIndices: options 56 | .map((item, i) => (item.disabled ? i : null)) 57 | .filter((op) => op !== null), 58 | }, 59 | (index) => { 60 | if (index === undefined) { 61 | return resolve(undefined) 62 | } else { 63 | return resolve(options[index]) 64 | } 65 | }, 66 | ), 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ActivityIndicator, 3 | Pressable, 4 | PressableProps, 5 | StyleSheet, 6 | View, 7 | } from "react-native" 8 | import { useTheme } from "@react-navigation/native" 9 | 10 | import { Text } from "./views" 11 | 12 | export function Button({ 13 | title, 14 | style, 15 | loading, 16 | disabled, 17 | ...props 18 | }: Omit & { title: string; loading?: boolean }) { 19 | const theme = useTheme() 20 | return ( 21 | [ 23 | styles.button, 24 | { backgroundColor: theme.colors.primary }, 25 | disabled && styles.disabled, 26 | state.hovered && styles.hovered, 27 | state.pressed && styles.pressed, 28 | typeof style === "function" ? style(state) : style, 29 | ]} 30 | disabled={disabled || loading} 31 | {...props} 32 | > 33 | 34 | {title} 35 | 36 | {loading && ( 37 | 38 | 39 | 40 | )} 41 | 42 | ) 43 | } 44 | 45 | const styles = StyleSheet.create({ 46 | button: { 47 | flex: 1, 48 | paddingHorizontal: 16, 49 | paddingVertical: 12, 50 | borderRadius: 12, 51 | borderCurve: "continuous", 52 | flexDirection: "row", 53 | justifyContent: "center", 54 | alignItems: "center", 55 | gap: 12, 56 | }, 57 | text: { 58 | fontSize: 14, 59 | fontWeight: 500, 60 | textAlign: "center", 61 | }, 62 | disabled: { 63 | filter: [{ brightness: 0.75 }], 64 | }, 65 | hovered: { 66 | filter: [{ brightness: 1.05 }], 67 | }, 68 | pressed: { 69 | filter: [{ brightness: 1.1 }], 70 | }, 71 | loading: { 72 | height: 0, 73 | justifyContent: "center", 74 | }, 75 | }) 76 | -------------------------------------------------------------------------------- /src/components/header-buttons.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react" 2 | import { Pressable, StyleSheet, TouchableOpacity, View } from "react-native" 3 | import { Link, useRouter } from "expo-router" 4 | import { useTheme } from "@react-navigation/native" 5 | import { XIcon } from "lucide-react-native" 6 | 7 | import { isIOS26 } from "#/lib/versions" 8 | 9 | import { Text } from "./views" 10 | 11 | export function useSheetCloseButton(title?: string, bold?: "bold") { 12 | const router = useRouter() 13 | const theme = useTheme() 14 | 15 | return useCallback(() => { 16 | if (router.canDismiss()) { 17 | return ( 18 | 19 | 20 | {isIOS26 ? ( 21 | 31 | 32 | 33 | ) : ( 34 | 35 | 39 | {title ?? "Close"} 40 | 41 | 42 | )} 43 | 44 | 45 | ) 46 | } else { 47 | return null 48 | } 49 | }, [router, title, bold]) 50 | } 51 | 52 | const styles = StyleSheet.create({ 53 | buttons: { 54 | flexDirection: "row", 55 | }, 56 | textButton: { 57 | fontSize: 16, 58 | }, 59 | bold: { 60 | fontWeight: 600, 61 | }, 62 | }) 63 | -------------------------------------------------------------------------------- /src/lib/agent.ts: -------------------------------------------------------------------------------- 1 | import { Agent } from "@atproto/api" 2 | import { useQuery } from "@tanstack/react-query" 3 | 4 | const PLC_DIRECTORY = "https://plc.directory" 5 | 6 | export const publicAgent = new Agent({ 7 | service: "https://public.api.bsky.app", 8 | }) 9 | 10 | export function useIdentityQuery( 11 | identifier: string, 12 | { enabled } = { enabled: true }, 13 | ) { 14 | return useQuery({ 15 | queryKey: ["identity", identifier], 16 | queryFn: async () => { 17 | let did 18 | 19 | if (identifier.startsWith("did:plc:")) { 20 | did = identifier 21 | } else if (identifier.startsWith("did:")) { 22 | throw new Error("Unsupported DID method") 23 | } else { 24 | const res = await publicAgent.resolveHandle({ 25 | handle: identifier, 26 | }) 27 | did = res.data.did 28 | } 29 | 30 | const plcData: PlcData = await fetch(`${PLC_DIRECTORY}/${did}/data`).then( 31 | (res) => res.json(), 32 | ) 33 | 34 | const pds = plcData.services?.atproto_pds?.endpoint 35 | 36 | if (!pds) { 37 | throw new Error("Found DID doc, but it had no associated PDS") 38 | } 39 | 40 | return { 41 | did, 42 | plcData, 43 | pds, 44 | } 45 | }, 46 | enabled, 47 | }) 48 | } 49 | 50 | export type DidDocument = { 51 | "@context": string[] 52 | id: string 53 | alsoKnownAs?: string[] 54 | verificationMethod?: { 55 | id: string 56 | type: string 57 | controller: string 58 | publicKeyMultibase?: string 59 | }[] 60 | service?: { id: string; type: string; serviceEndpoint: string }[] 61 | } 62 | 63 | export type PlcData = { 64 | did: string 65 | verificationMethods?: { 66 | atproto?: "did:key:zQ3shhB2yHGn8JbXPAYX6LcEBrWo1nALhWLGa11YBqU2zB9Sm" 67 | [key: string]: unknown 68 | } 69 | rotationKeys: string[] 70 | alsoKnownAs: string[] 71 | services?: { 72 | atproto_pds?: { 73 | type: "AtprotoPersonalDataServer" 74 | endpoint: "https://amanita.us-east.host.bsky.network" 75 | } 76 | [key: string]: unknown 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigContext, ExpoConfig } from "expo/config" 2 | 3 | export default ({ config }: ConfigContext): ExpoConfig => ({ 4 | ...config, 5 | name: "Demesne", 6 | slug: "demesne", 7 | version: "1.0.0", 8 | orientation: "portrait", 9 | icon: "./assets/images/demesne.png", 10 | scheme: "demesne", 11 | userInterfaceStyle: "automatic", 12 | newArchEnabled: true, 13 | ios: { 14 | bundleIdentifier: "dev.mozzius.demesne", 15 | supportsTablet: true, 16 | infoPlist: { 17 | ITSAppUsesNonExemptEncryption: false, 18 | }, 19 | }, 20 | android: { 21 | edgeToEdgeEnabled: true, 22 | package: "dev.mozzius.demesne", 23 | adaptiveIcon: { 24 | foregroundImage: "./assets/images/demesne-nobg.png", 25 | backgroundColor: "#4DA5C1", 26 | }, 27 | }, 28 | web: { 29 | bundler: "metro", 30 | output: "server", 31 | favicon: "./assets/images/favicon.png", 32 | }, 33 | plugins: [ 34 | "expo-font", 35 | "expo-web-browser", 36 | "expo-localization", 37 | [ 38 | "expo-router", 39 | { 40 | origin: 41 | process.env.NODE_ENV === "development" 42 | ? "http://localhost:8081" 43 | : "https://demesne.expo.app", 44 | }, 45 | ], 46 | [ 47 | "expo-splash-screen", 48 | { 49 | image: "./assets/images/demesne-nobg.png", 50 | imageWidth: 200, 51 | resizeMode: "contain", 52 | backgroundColor: "#4DA5C1", 53 | }, 54 | ], 55 | [ 56 | "expo-secure-store", 57 | { 58 | configureAndroidBackup: true, 59 | faceIDPermission: 60 | "Allow Demesne to access your Face ID biometric data.", 61 | }, 62 | ], 63 | [ 64 | "react-native-edge-to-edge", 65 | { 66 | android: { 67 | parentTheme: "Default", 68 | enforceNavigationBarContrast: false, 69 | }, 70 | }, 71 | ], 72 | ], 73 | experiments: { 74 | typedRoutes: true, 75 | }, 76 | extra: { 77 | router: { 78 | origin: false, 79 | }, 80 | eas: { 81 | projectId: "c7f815ec-d876-40cf-91c0-5e4f19f17000", 82 | }, 83 | }, 84 | }) 85 | -------------------------------------------------------------------------------- /src/components/views.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useMemo } from "react" 2 | import { 3 | Text as RNText, 4 | TextProps, 5 | type FlatList as RNFlatList, 6 | } from "react-native" 7 | import Animated, { 8 | AnimatedScrollViewProps, 9 | FlatListPropsWithLayout, 10 | } from "react-native-reanimated" 11 | import { useTheme } from "@react-navigation/native" 12 | 13 | export function ScrollView(props: AnimatedScrollViewProps) { 14 | return ( 15 | 19 | ) 20 | } 21 | 22 | function FlatListInner( 23 | props: FlatListPropsWithLayout, 24 | ref: React.ForwardedRef>, 25 | ) { 26 | return ( 27 | 28 | ref={ref} 29 | contentInsetAdjustmentBehavior="automatic" 30 | {...props} 31 | /> 32 | ) 33 | } 34 | 35 | export const FlatList = forwardRef(FlatListInner) as ( 36 | props: FlatListPropsWithLayout & { 37 | ref?: React.ForwardedRef> 38 | }, 39 | ) => React.ReactElement 40 | 41 | export function useTextColor( 42 | color: "primary" | "secondary" | "tertiary" | "accent", 43 | ) { 44 | const theme = useTheme() 45 | let colorValue = theme.colors.text 46 | if (color === "secondary") { 47 | colorValue = theme.dark ? "#999" : "#777" 48 | } else if (color === "tertiary") { 49 | colorValue = theme.dark ? "#666" : "#aaa" 50 | } else if (color === "accent") { 51 | colorValue = theme.colors.primary 52 | } 53 | return colorValue 54 | } 55 | 56 | export function Text({ 57 | color: colorProp = "primary", 58 | style, 59 | ...props 60 | }: TextProps & { 61 | color?: "primary" | "secondary" | "tertiary" | "accent" | (string & {}) 62 | }) { 63 | const isColorThemed = 64 | colorProp === "primary" || 65 | colorProp === "secondary" || 66 | colorProp === "tertiary" || 67 | colorProp === "accent" 68 | let color = useTextColor( 69 | isColorThemed 70 | ? (colorProp as "primary" | "secondary" | "tertiary" | "accent") 71 | : "primary", 72 | ) 73 | if (!isColorThemed) color = colorProp 74 | const styleWithColor = useMemo(() => { 75 | return [{ color }, style] 76 | }, [color, style]) 77 | return 78 | } 79 | -------------------------------------------------------------------------------- /src/app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import "react-native-get-random-values" 2 | 3 | import { useColorScheme } from "react-native" 4 | import { KeyboardProvider } from "react-native-keyboard-controller" 5 | import { Stack } from "expo-router" 6 | import { 7 | DarkTheme, 8 | DefaultTheme, 9 | ThemeProvider, 10 | } from "@react-navigation/native" 11 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query" 12 | 13 | import { coolLargeTitleEffect, coolTitleEffect } from "#/components/header" 14 | import { AccountProvider } from "#/lib/accounts" 15 | import { isIOS26 } from "#/lib/versions" 16 | 17 | export const unstable_settings = { 18 | initialRouteName: "index", 19 | } 20 | 21 | const queryClient = new QueryClient() 22 | 23 | export default function RootLayout() { 24 | const scheme = useColorScheme() 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 36 | 43 | 50 | 57 | 64 | 72 | 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demesne", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo run:android", 8 | "ios": "expo run:ios", 9 | "web": "expo start --web", 10 | "test": "jest --watchAll", 11 | "lint": "expo lint", 12 | "format": "prettier --write .", 13 | "prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean" 14 | }, 15 | "jest": { 16 | "preset": "jest-expo" 17 | }, 18 | "dependencies": { 19 | "@atproto/api": "^0.16.9", 20 | "@atproto/crypto": "^0.4.4", 21 | "@expo/ui": "0.2.0-beta.3", 22 | "@react-native-async-storage/async-storage": "2.2.0", 23 | "@react-navigation/bottom-tabs": "^7.3.14", 24 | "@react-navigation/elements": "^2.4.3", 25 | "@react-navigation/native": "^7.1.17", 26 | "@tanstack/react-query": "^5.87.4", 27 | "base64-js": "^1.5.1", 28 | "dequal": "^2.0.3", 29 | "expo": "~54.0.7", 30 | "expo-blur": "~15.0.7", 31 | "expo-clipboard": "^8.0.7", 32 | "expo-constants": "~18.0.9", 33 | "expo-dev-client": "~6.0.12", 34 | "expo-file-system": "^19.0.14", 35 | "expo-font": "~14.0.8", 36 | "expo-haptics": "~15.0.7", 37 | "expo-image": "^3.0.8", 38 | "expo-insights": "~0.10.7", 39 | "expo-linking": "~8.0.8", 40 | "expo-localization": "~17.0.7", 41 | "expo-router": "~6.0.7", 42 | "expo-secure-store": "^15.0.7", 43 | "expo-splash-screen": "~31.0.10", 44 | "expo-status-bar": "~3.0.8", 45 | "expo-symbols": "~1.0.7", 46 | "expo-system-ui": "~6.0.7", 47 | "expo-web-browser": "~15.0.7", 48 | "lodash.difference": "^4.5.0", 49 | "lucide-react-native": "^0.544.0", 50 | "react": "19.1.0", 51 | "react-dom": "19.1.0", 52 | "react-native": "0.81.4", 53 | "react-native-edge-to-edge": "^1.7.0", 54 | "react-native-gesture-handler": "~2.28.0", 55 | "react-native-get-random-values": "~1.11.0", 56 | "react-native-keyboard-controller": "^1.18.6", 57 | "react-native-reanimated": "~4.1.0", 58 | "react-native-safe-area-context": "5.6.1", 59 | "react-native-screens": "~4.16.0", 60 | "react-native-svg": "15.12.1", 61 | "react-native-web": "~0.21.1", 62 | "react-native-webview": "13.15.0", 63 | "tlds": "^1.260.0", 64 | "zod": "^4.1.8" 65 | }, 66 | "devDependencies": { 67 | "@babel/core": "^7.28.4", 68 | "@ianvs/prettier-plugin-sort-imports": "^4.7.0", 69 | "@types/jest": "^30.0.0", 70 | "@types/lodash.difference": "^4.5.9", 71 | "@types/react": "~19.1.13", 72 | "@types/react-test-renderer": "^19.1.0", 73 | "eslint": "^9.35.0", 74 | "eslint-config-expo": "~10.0.0", 75 | "jest": "^29.7.0", 76 | "jest-expo": "~54.0.12", 77 | "prettier": "3.6.2", 78 | "react-test-renderer": "19.1.0", 79 | "typescript": "^5.9.2" 80 | }, 81 | "private": true, 82 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" 83 | } 84 | -------------------------------------------------------------------------------- /src/app/account/[did]/keys/add.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { StyleSheet, View } from "react-native" 3 | import { useLocalSearchParams, useRouter } from "expo-router" 4 | import * as SecureStore from "expo-secure-store" 5 | import { Secp256k1Keypair } from "@atproto/crypto" 6 | import { useMutation, useQueryClient } from "@tanstack/react-query" 7 | import base64 from "base64-js" 8 | 9 | import { Button } from "#/components/button" 10 | import { InputGroup, TextField } from "#/components/text-field" 11 | import { ScrollView, Text } from "#/components/views" 12 | import { useAccount, useSaveKey } from "#/lib/accounts" 13 | import { useIdentityQuery } from "#/lib/agent" 14 | import { isIOS26 } from "#/lib/versions" 15 | 16 | export default function AddKeyScreen() { 17 | const { agent } = useAccount() 18 | const router = useRouter() 19 | const [token, setToken] = useState("") 20 | const queryClient = useQueryClient() 21 | const { did } = useLocalSearchParams<{ did: string }>() 22 | const { data: identity } = useIdentityQuery(did) 23 | const saveKey = useSaveKey() 24 | 25 | const { mutate: createKey, isPending: isCreating } = useMutation({ 26 | mutationFn: async () => { 27 | if (!agent) throw new Error("no agent") 28 | if (!identity) throw new Error("no plcData") 29 | if (!token) throw new Error("no token") 30 | 31 | const key = await Secp256k1Keypair.create({ exportable: true }) 32 | const pubkey = key.did() 33 | const privkey = base64.fromByteArray(await key.export()) 34 | 35 | SecureStore.setItemAsync(pubkey.replace("did:key:", ""), privkey, { 36 | requireAuthentication: !__DEV__, 37 | }) 38 | 39 | const rotationKeys = [pubkey, ...identity.plcData.rotationKeys] 40 | 41 | const signedOp = await agent.com.atproto.identity.signPlcOperation({ 42 | rotationKeys, 43 | token: token.trim(), 44 | }) 45 | 46 | await agent.com.atproto.identity.submitPlcOperation({ 47 | operation: signedOp.data.operation, 48 | }) 49 | 50 | return pubkey 51 | }, 52 | onError: (err) => { 53 | console.error(err) 54 | }, 55 | onSuccess: (key) => { 56 | saveKey(did, key) 57 | queryClient.invalidateQueries({ queryKey: ["identity"] }) 58 | router.dismiss() 59 | }, 60 | }) 61 | 62 | return ( 63 | 64 | 65 | 66 | A code has been sent to your email. Enter it here. 67 | 68 | 69 | 75 | 76 |