# 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) ├── features/ # Macro-features that bridge components/screens ├── 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 ``` ### Project Structure in Depth When building new things, follow these guidelines for where to put code. #### Components vs Screens vs Features **Components** are reusable UI elements that are not full screens. Should be platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put these in `/components` if they are shared across screens. **Screens** are full-page components that represent a route in the app. They often contain multiple components and handle layout for a page. New screens should go in `/screens` (not `/view/screens`) to encourage better organization and separation from legacy code. For complex screens that have specific components or data needs that _are not shared by other screens_, we encourage subdirectoreis within `/screens/` e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and `/screens/ProfileScreen/components/`. **Features** are higher-level modules that may include context, data fetching, components, and utilities related to a specific feature e.g. `/features/liveNow`. They don't neatly fit into components or screens and often span multiple screens. This is an optional pattern for organizing complex features. #### Legacy Directories For the most part, avoid writing new files into the `/view` directory and subdirectories. This is the older pattern for organizing screens and components, and it has become a bit disorganized over time. New development should go into `/screens`, `/components`, and `/features`. #### State The `/state` directory is where we've historically put all our data fetching and state management logic. This is perfectly fine, but for new features, consider organizing state logic closer to the components that use it, either within a feature directory or co-located with a screen. The key is to keep related code together and avoid having "god files" with too much unrelated logic. #### Lib The `/lib` directory is for utilities and helpers that don't fit into other categories. This can include things like API clients, formatting functions, constants, and other shared logic. #### Top Level Directories Avoid writing new top-level subdirectories within `/src`. We've done this for a few things in the past that, but we have stronger patterns now. Examples: `/logger` should probably have been written into `/lib`. And `ageAssurance` is better classified within `/features`. We will probably migrate these things eventually. ### File and Directory Naming Conventions Typically JS style for variables, functions, etc. We use ProudCamelCase for components, and camelCase directories and files. When organizing new code, consider if it fits into a single file, or if it should be broken down into multiple files. For "macro" component cases, or things that live in `/features` or `/screens`, we often follow a pattern of having an `index.tsx` for the main component, and then co-locating related components, hooks, and utilities in the same directory. For example: ``` src ├── screens/ │ ├── ProfileScreen/ │ │ ├── index.tsx # Main screen component │ │ ├── components/ # Sub-components used only by this screen ``` Similar patterns can be found in `/features` and `/components`. The idea here is to keep related code together and make it easier to navigate. You should ask yourself: if someone new was looking for the code related to this feature or screen, where would they expect to find it? Organizing code in a way that matches developer expectations can make the codebase much more approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is easier to understand than having it scattered across multiple directories. No need to go overboard with this. If a component or feature fits into a single file, there's no reason to have a `/Component/index.tsx` file when it could just be `/Component.tsx`. Use your judgment based on the complexity and amount of related code. #### Platform Specific Files We have conflicting patterns in the app for this. The preferred approach is to group platform-specific files into a directory as much as possible. For example, rather than having `Component.tsx`, `Component.web.tsx`, and `Component.native.tsx` in the same directory, we prefer to have a `Component/` directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps related code together and gives us a better visual cue that there are probably other files contained within this "macro" feature, whereas `Component.tsx` on its own looks more like a single component file. ### Documentation and Tests Within Features For larger features or components, it's helpful to include a README.md file within the directory that explains the purpose of the feature, how it works, and any important implementation details. The `/Component/index.tsx` pattern lends itself well to this, since the `index.tsx` can be the main component file, and the `README.md` can provide documentation for the whole feature. This is optional, but can be a nice way to keep documentation close to the code it describes. Similarly, if there are tests that are specific to a component or feature, it can be helpful to include them in the same directory, either as `Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything related to the component or feature in one place and makes it easier to find and maintain tests. ## 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` |