Repository: bluesky-social/social-app
Stars: 17877
CLAUDE.md
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
Development
yarn start # Start Expo dev server
yarn web # Start web version
yarn android # Run on Android
yarn ios # Run on iOSTesting & 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 checkingInternationalization
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 projectsProject 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 configurationProject 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/<name>
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 screenSimilar 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, andComponent.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 asComponent.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
import {atoms as a, useTheme} from '#/alf'function MyComponent() {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
Hello
</Text>
</View>
)
}
Key Concepts
Static Atoms - Theme-independent styles imported from atoms:
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():
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:
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:
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.
import * as Dialog from '#/components/Dialog'function MyFeature() {
const control = Dialog.useDialogControl()
return (
<>
<Button label="Open" onPress={control.open}>
<ButtonText>Open Dialog</ButtonText>
</Button>
<Dialog.Outer control={control}>
{/ Typically the inner part is in its own component /}
<Dialog.Handle /> {/ Native-only drag handle /}
<Dialog.ScrollableInner label={_(msgMy Dialog)}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/ Web-only X button in top left /}
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
Menu Component
Menus render as a dropdown on web and a bottom sheet dialog on native.
import * as Menu from '#/components/Menu'function MyMenu() {
return (
<Menu.Root>
<Menu.Trigger label="Open menu">
{({props}) => (
<Button {...props} label="Menu">
<ButtonIcon icon={DotsHorizontal} />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item label="Edit" onPress={handleEdit}>
<Menu.ItemIcon icon={Pencil} />
<Menu.ItemText>Edit</Menu.ItemText>
</Menu.Item>
<Menu.Item label="Delete" onPress={handleDelete}>
<Menu.ItemIcon icon={Trash} />
<Menu.ItemText>Delete</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
Button Component
import {Button, ButtonText, ButtonIcon} from '#/components/Button'// Solid primary button (most common)
<Button label="Save" onPress={handleSave} color="primary" size="large">
<ButtonText>Save</ButtonText>
</Button>
// With icon
<Button label="Share" onPress={handleShare} color="secondary" size="small">
<ButtonIcon icon={Share} />
<ButtonText>Share</ButtonText>
</Button>
// Icon-only button
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
<ButtonIcon icon={XIcon} />
</Button>
// Ghost variant (deprecated - use color prop)
<Button label="Cancel" variant="ghost" color="secondary" size="small">
<ButtonText>Cancel</ButtonText>
</Button>
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
import {Text, H1, H2, P} from '#/components/Typography'<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop
<Text emoji>Hello! 👋</Text>
TextField
import * as TextField from '#/components/forms/TextField'<TextField.LabelText>Email</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={AtSign} />
<TextField.Input
label="Email address"
placeholder="[email protected]"
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</TextField.Root>
Internationalization (i18n)
All user-facing strings must be wrapped for translation using Lingui.
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 = _(msgSettings)
const errorMessage = _(msgSomething went wrong)
// Strings with variables
const greeting = _(msgHello, ${name}!)
// Pluralization
const countLabel = _(plural(count, {
one: '# item',
other: '# items',
}))
// JSX content - use Trans component
return (
<Text>
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
</Text>
)
}
Commands:
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 runtimeState Management
TanStack Query (Data Fetching)
// src/state/queries/profile.ts
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'import {createQueryKey} from '#/state/queries/util'
/*
* Query key name should match the query hook name for consistency
*/
const profileQueryKeyRoot = 'profile'
/*
* Use object params and createQueryKey helper for better readability and to
* avoid bugs with parameter order or types.
*/
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args)
/*
* Query hook should be named use[Name]Query, where [Name] describes the data
* being fetched. This is not a strict requirement, but it's a helpful
* convention for discoverability
*/
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: createProfileQueryKey({did}),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
},
staleTime: STALE.MINUTES.FIVE,
enabled: !!did,
})
}
/*
* Mutation hook should match the name of the query hook, but with "Mutation"
* suffix. This is not a strict requirement, but it's a helpful convention for
* discoverability and consistency.
*/
export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data) => {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: createProfileQueryKey({did: 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})
}
}
})
}
/*
* If cache mutation is needed, include specific interfaces for the specific
* mutations you require adjacent to the source queries. Naming should be
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
* not a strict requirement, but it's a helpful convention for discoverability
* and consistency.
*/
export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial<Profile>) => {
queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
})
}
}
Stale Time Constants (from src/state/queries/index.ts):
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stalePaginated APIs: Many atproto APIs return paginated results with a cursor. Use useInfiniteQuery for these:
export function useDraftsQuery() {
const agent = useAgent() return useInfiniteQuery({
queryKey: createQueryKey('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) ?? []
Persisted Queries
To persist query data across app restarts, createQueryKey supports a third
parameter called options, which has a persistedVersion property. When this
property is set to a number, the query will be persisted.
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})Preferences (React Context)
// Simple boolean preference pattern
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return (
<Toggle
value={autoplayDisabled}
onValueChange={setAutoplayDisabled}
/>
)
}
Session State
import {useSession, useAgent} from '#/state/session'function MyComponent() {
const {hasSession, currentAccount} = useSession()
const agent = useAgent()
if (!hasSession) {
return <LoginPrompt />
}
// Use agent for API calls
const response = await agent.getProfile({actor: currentAccount.did})
}
Navigation
Navigation uses React Navigation with type-safe route parameters.
// Screen component
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
return (
<Layout.Screen>
{/ Screen content /}
</Layout.Screen>
)
}
// 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-onlyExample 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:
// 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):
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:
// 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.
// 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:
// Preferred - uncontrolled
<TextField.Input
defaultValue={initialEmail}
onChangeText={setEmail}
/>// Avoid when possible - controlled (can cause performance issues)
<TextField.Input
value={email}
onChangeText={setEmail}
/>
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.
// 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 <Trans>
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 |
README.md
Bluesky Social App
Welcome friends! This is the codebase for the Bluesky Social app.
Get the app itself:
- Web: bsky.app
- iOS: App Store
- Android: Play Store
Development Resources
This is a React Native application, written in the TypeScript programming language. It builds on the atproto TypeScript packages (like @atproto/api), which are also open source, but in a different git repository.
There is a small amount of Go language source code (in ./bskyweb/), for a web service that returns the React Native Web application.
The Build Instructions are a good place to get started with the app itself.
The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentralized social media protocol. You don't need to understand AT Protocol to work with this application, but it can help. Learn more at:
- Overview and Guides
- GitHub Discussions 👈 Great place to ask questions
- Protocol Specifications
- Blogpost on self-authenticating data structures
The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is app.bsky.*.
Contributions
While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
Rules:
- We may not respond to your issue or PR.
- We may close an issue or PR without much feedback.
- We may lock discussions or contributions if our attention is getting DDOSed.
- We're not going to provide support for build issues.
Guidelines:
- Check for existing issues before filing a new one please.
- Open an issue and give some time for discussion before submitting a PR.
- Stay away from PRs like...
- Changing "Post" to "Skeet."
- Refactoring the codebase, e.g., to replace React Query with Redux Toolkit or something.
- Adding entirely new features without prior discussion.
Remember, we serve a wide community of users. Our day-to-day involves us constantly asking "which top priority is our top priority." If you submit well-written PRs that solve problems concisely, that's an awesome contribution. Otherwise, as much as we'd love to accept your ideas and contributions, we really don't have the bandwidth. That's what forking is for!
Forking guidelines
You have our blessing 🪄✨ to fork this application! However, it's very important to be clear to users when you're giving them a fork.
Please be sure to:
- Change all branding in the repository and UI to clearly differentiate from Bluesky.
- Change any support links (feedback, email, terms of service, etc) to your own systems.
- Replace any analytics or error-collection systems with your own so we don't get super confused.
Security disclosures
If you discover any security issues, please send an email to [email protected]. The email is automatically CC'd to the entire team and we'll respond promptly.
Are you a developer interested in building on atproto?
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party integration can be as seamless as first-party through custom feeds, federated services, clients, and more.
License (MIT)
See ./LICENSE for the full license.
Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see the original announcement.
P.S.
We ❤️ you and all of the ways you support us. Thank you for making Bluesky a great place!