# CLAUDE.md - Bluesky Social App Development Guide This document provides guidance for working effectively in the Bluesky Social app codebase. ## Project Overview Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network. **Tech Stack:** - React Native 0.81 with Expo 54 - TypeScript - React Navigation for routing - TanStack Query (React Query) for data fetching - Lingui for internationalization - Custom design system called ALF (Application Layout Framework) ## Essential Commands ```bash # Development yarn start # Start Expo dev server yarn web # Start web version yarn android # Run on Android yarn ios # Run on iOS # Testing & Quality # IMPORTANT: Always use these yarn scripts, never call the underlying tools directly yarn test # Run Jest tests yarn lint # Run ESLint yarn typecheck # Run TypeScript type checking # Internationalization # DO NOT run these commands - extraction and compilation are handled by CI yarn intl:extract # Extract translation strings (nightly CI job) yarn intl:compile # Compile translations for runtime (nightly CI job) # Build yarn build-web # Build web version yarn prebuild # Generate native projects ``` ## Project Structure ``` src/ ├── alf/ # Design system (ALF) - themes, atoms, tokens ├── components/ # Shared UI components (Button, Dialog, Menu, etc.) ├── screens/ # Full-page screen components (newer pattern) ├── view/ │ ├── screens/ # Full-page screens (legacy location) │ ├── com/ # Reusable view components │ └── shell/ # App shell (navigation bars, tabs) ├── state/ │ ├── queries/ # TanStack Query hooks │ ├── preferences/ # User preferences (React Context) │ ├── session/ # Authentication state │ └── persisted/ # Persistent storage layer ├── lib/ # Utilities, constants, helpers ├── locale/ # i18n configuration and language files └── Navigation.tsx # Main navigation configuration ``` ## Styling System (ALF) ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens. ### Basic Usage ```tsx import {atoms as a, useTheme} from '#/alf' function MyComponent() { const t = useTheme() return ( Hello ) } ``` ### Key Concepts **Static Atoms** - Theme-independent styles imported from `atoms`: ```tsx import {atoms as a} from '#/alf' // a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc. ``` **Theme Atoms** - Theme-dependent colors from `useTheme()`: ```tsx const t = useTheme() // t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc. // t.palette.primary_500, t.palette.negative_400, etc. ``` **Platform Utilities** - For platform-specific styles: ```tsx import {web, native, ios, android, platform} from '#/alf' const styles = [ a.p_md, web({cursor: 'pointer'}), native({paddingBottom: 20}), platform({ios: {...}, android: {...}, web: {...}}), ] ``` **Breakpoints** - Responsive design: ```tsx import {useBreakpoints} from '#/alf' const {gtPhone, gtMobile, gtTablet} = useBreakpoints() if (gtMobile) { // Tablet or desktop layout } ``` ### Naming Conventions - Spacing: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (t-shirt sizes) - Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl` - Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl` - Flex: `flex_row`, `flex_1`, `align_center`, `justify_between` - Borders: `border`, `border_t`, `rounded_md`, `rounded_full` ## Component Patterns ### Dialog Component Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. ```tsx import * as Dialog from '#/components/Dialog' function MyFeature() { const control = Dialog.useDialogControl() return ( <> {/* Typically the inner part is in its own component */} {/* Native-only drag handle */} Title Dialog content here {/* Web-only X button in top left */} ) } ``` ### Menu Component Menus render as a dropdown on web and a bottom sheet dialog on native. ```tsx import * as Menu from '#/components/Menu' function MyMenu() { return ( {({props}) => ( )} Edit Delete ) } ``` ### Button Component ```tsx import {Button, ButtonText, ButtonIcon} from '#/components/Button' // Solid primary button (most common) // With icon // Icon-only button // Ghost variant (deprecated - use color prop) ``` **Button Props:** - `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'` - `size`: `'tiny'` | `'small'` | `'large'` - `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` - `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`) ### Typography ```tsx import {Text, H1, H2, P} from '#/components/Typography'

Heading

Paragraph text with default styling.

Custom text // For text with emoji, add the emoji prop Hello! 👋 ``` ### TextField ```tsx import * as TextField from '#/components/forms/TextField' Email ``` ## Internationalization (i18n) All user-facing strings must be wrapped for translation using Lingui. ```tsx import {msg, plural} from '@lingui/core/macro' import {Trans} from '@lingui/react/macro' import {useLingui} from '@lingui/react' function MyComponent() { const {_} = useLingui() // Simple strings - use msg() with _() function const title = _(msg`Settings`) const errorMessage = _(msg`Something went wrong`) // Strings with variables const greeting = _(msg`Hello, ${name}!`) // Pluralization const countLabel = _(plural(count, { one: '# item', other: '# items', })) // JSX content - use Trans component return ( Welcome to Bluesky ) } ``` **Commands:** ```bash # DO NOT run these commands - extraction and compilation are handled by a nightly CI job yarn intl:extract # Extract new strings to locale files yarn intl:compile # Compile translations for runtime ``` ## State Management ### TanStack Query (Data Fetching) ```tsx // src/state/queries/profile.ts import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' // Query key pattern const RQKEY_ROOT = 'profile' export const RQKEY = (did: string) => [RQKEY_ROOT, did] // Query hook export function useProfileQuery({did}: {did: string}) { const agent = useAgent() return useQuery({ queryKey: RQKEY(did), queryFn: async () => { const res = await agent.getProfile({actor: did}) return res.data }, staleTime: STALE.MINUTES.FIVE, enabled: !!did, }) } // Mutation hook export function useUpdateProfile() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (data) => { // Update logic }, onSuccess: (_, variables) => { queryClient.invalidateQueries({queryKey: RQKEY(variables.did)}) }, onError: (error) => { if (isNetworkError(error)) { // don't log, but inform user } else if (error instanceof AppBskyExampleProcedure.ExampleError) { // XRPC APIs often have typed errors, allows nicer handling } else { // Log unexpected errors to Sentry logger.error('Error updating profile', {safeMessage: error}) } } }) } ``` **Stale Time Constants** (from `src/state/queries/index.ts`): ```tsx STALE.SECONDS.FIFTEEN // 15 seconds STALE.MINUTES.ONE // 1 minute STALE.MINUTES.FIVE // 5 minutes STALE.HOURS.ONE // 1 hour STALE.INFINITY // Never stale ``` **Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these: ```tsx export function useDraftsQuery() { const agent = useAgent() return useInfiniteQuery({ queryKey: ['drafts'], queryFn: async ({pageParam}) => { const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam}) return res.data }, initialPageParam: undefined as string | undefined, getNextPageParam: page => page.cursor, }) } ``` To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []` ### Preferences (React Context) ```tsx // Simple boolean preference pattern import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' function SettingsScreen() { const autoplayDisabled = useAutoplayDisabled() const setAutoplayDisabled = useSetAutoplayDisabled() return ( ) } ``` ### Session State ```tsx import {useSession, useAgent} from '#/state/session' function MyComponent() { const {hasSession, currentAccount} = useSession() const agent = useAgent() if (!hasSession) { return } // Use agent for API calls const response = await agent.getProfile({actor: currentAccount.did}) } ``` ## Navigation Navigation uses React Navigation with type-safe route parameters. ```tsx // Screen component import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type CommonNavigatorParams} from '#/lib/routes/types' type Props = NativeStackScreenProps export function ProfileScreen({route, navigation}: Props) { const {name} = route.params // Type-safe params return ( {/* Screen content */} ) } // Programmatic navigation import {useNavigation} from '@react-navigation/native' const navigation = useNavigation() navigation.navigate('Profile', {name: 'alice.bsky.social'}) // Or use the navigate helper import {navigate} from '#/Navigation' navigate('Profile', {name: 'alice.bsky.social'}) ``` ## Platform-Specific Code Use file extensions for platform-specific implementations: ``` Component.tsx # Shared/default Component.web.tsx # Web-only Component.native.tsx # iOS + Android Component.ios.tsx # iOS-only Component.android.tsx # Android-only ``` Example from Dialog: - `src/components/Dialog/index.tsx` - Native (uses BottomSheet) - `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives) **Important:** The bundler automatically resolves platform-specific files. Just import normally: ```tsx // CORRECT - bundler picks storage.ts or storage.web.ts automatically import * as storage from '#/state/drafts/storage' // WRONG - don't use require() or conditional imports for platform files const storage = IS_NATIVE ? require('#/state/drafts/storage') : require('#/state/drafts/storage.web') ``` Platform detection (for runtime logic, not imports): ```tsx import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env' if (IS_NATIVE) { // Native-specific logic } ``` ## Import Aliases Always use the `#/` alias for absolute imports: ```tsx // Good import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' // Avoid import {useSession} from '../../../state/session' ``` ## Footguns Common pitfalls to avoid in this codebase: ### Dialog Close Callback (Critical) **Always use `control.close(() => ...)` when performing actions after closing a dialog.** The callback ensures the action runs after the dialog's close animation completes. Failing to do this causes race conditions with React state updates. ```tsx // WRONG - causes bugs with state updates, navigation, opening other dialogs const onConfirm = () => { control.close() navigation.navigate('Home') // May race with dialog animation } // WRONG - same problem const onConfirm = () => { control.close() otherDialogControl.open() // Will likely fail or cause visual glitches } // CORRECT - action runs after dialog fully closes const onConfirm = () => { control.close(() => { navigation.navigate('Home') }) } // CORRECT - opening another dialog after close const onConfirm = () => { control.close(() => { otherDialogControl.open() }) } // CORRECT - state updates after close const onConfirm = () => { control.close(() => { setSomeState(newValue) onCallback?.() }) } ``` This applies to: - Navigation (`navigation.navigate()`, `navigation.push()`) - Opening other dialogs or menus - State updates that affect UI (`setState`, `queryClient.invalidateQueries`) - Callbacks passed from parent components The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`. ### Controlled vs Uncontrolled Inputs Prefer `defaultValue` over `value` for TextInput on the old architecture: ```tsx // Preferred - uncontrolled // Avoid when possible - controlled (can cause performance issues) ``` ### Platform-Specific Behavior Some components behave differently across platforms: - `Dialog.Handle` - Only renders on native (drag handle for bottom sheet) - `Dialog.Close` - Only renders on web (X button) - `Menu.Divider` - Only renders on web - `Menu.ContainerItem` - Only works on native Always test on multiple platforms when using these components. ### React Compiler is Enabled This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically. ```tsx // UNNECESSARY - React Compiler handles this const handlePress = useCallback(() => { doSomething() }, [doSomething]) // JUST WRITE THIS const handlePress = () => { doSomething() } ``` Only use `useMemo`/`useCallback` when you have a specific reason, such as: - The value is immediately used in an effect's dependency array - You're passing a callback to a non-React library that needs referential stability ## Best Practices 1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful 2. **Translations**: Wrap ALL user-facing strings with `msg()` or `` 3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles 4. **State**: Use TanStack Query for server state, React Context for UI preferences 5. **Components**: Check if a component exists in `#/components/` before creating new ones 6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens 7. **Testing**: Components should have `testID` props for E2E testing ## Key Files Reference | Purpose | Location | |---------|----------| | Theme definitions | `src/alf/themes.ts` | | Design tokens | `src/alf/tokens.ts` | | Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) | | Navigation config | `src/Navigation.tsx` | | Route definitions | `src/routes.ts` | | Route types | `src/lib/routes/types.ts` | | Query hooks | `src/state/queries/*.ts` | | Session state | `src/state/session/index.tsx` | | i18n setup | `src/locale/i18n.ts` |