# 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
yarn test # Run Jest tests
yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
# Internationalization
yarn intl:extract # Extract translation strings
yarn intl:compile # Compile translations for runtime
# 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: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `xxl` (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 (
<>
{/* Native drag handle */}
Title
Dialog content here
>
)
}
```
### 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'`
- `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, Trans, plural} from '@lingui/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
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile for runtime (required after changes)
```
## 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)})
},
})
}
```
**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
```
### 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)
Platform detection:
```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` |