diff --git a/CLAUDE.md b/CLAUDE.md
index a15b5bad88..4949a54d3f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,4 +1,4 @@
-# CLAUDE.md - Bluesky Social App Development Guide
+# CLAUDE.md – Bluesky Social App Development Guide
This document provides guidance for working effectively in the Bluesky Social app codebase.
@@ -7,13 +7,17 @@ This document provides guidance for working effectively in the Bluesky Social ap
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 19.1
- React Native 0.81 with Expo 54
-- TypeScript
-- React Navigation for routing
+- TypeScript 6
+- React Navigation 7 for routing
- TanStack Query (React Query) for data fetching
-- Lingui for internationalization
+- Lingui 5 for internationalization
- Custom design system called ALF (Application Layout Framework)
+Prefer using the latest features available for each of these libraries (exact versions are found in `package.json`). For example, prefer `@lingui/react/macro` over `@lingui/react`. Suggest refactoring legacy or deprecated uses.
+
## Essential Commands
```bash
@@ -162,6 +166,10 @@ its own looks more like a single component file.
### Documentation and Tests Within Features
+Comment code when necessary to explain the “why” behind something; avoid
+comments that simply describe the code. Avoid Unicode characters in comments,
+e.g., use `-` not `—`.
+
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
@@ -182,6 +190,14 @@ ALF is the custom design system. It uses Tailwind-inspired naming with underscor
### Basic Usage
+Generally, order atoms by:
+
+- Flexbox configuration, e.g., `a.flex_row`
+- Spacing, e.g., `a.px_md`
+- Text styles, e.g., `a.font_bold`
+- Themes, e.g., `t.atoms.text`,
+- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
+
```tsx
import {atoms as a, useTheme} from '#/alf'
@@ -190,9 +206,7 @@ function MyComponent() {
return (
-
- Hello
-
+ Hello
)
}
@@ -200,20 +214,23 @@ function MyComponent() {
### Key Concepts
-**Static Atoms** - Theme-independent styles imported from `atoms`:
+**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()`:
+**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:
+**Platform Utilities** – For platform-specific styles:
+
```tsx
import {web, native, ios, android, platform} from '#/alf'
@@ -225,7 +242,8 @@ const styles = [
]
```
-**Breakpoints** - Responsive design:
+**Breakpoints** – Responsive design:
+
```tsx
import {useBreakpoints} from '#/alf'
@@ -245,6 +263,38 @@ if (gtMobile) {
## Component Patterns
+- Prefer fragment shorthand over `Fragment` unless a `key` is needed.
+- Prefer functions over arrow functions for component declarations.
+- Prefer prop destructuring via parameters over a const within the component.
+- Prefer inline types over `Props` types or interfaces.
+- Set reasonable defaults for optional props.
+
+```tsx
+import {Fragment} from 'react'
+import {View} from 'react-native'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {Text} from '#/components/Typography'
+
+function MyComponent({foo = []}: {foo?: string[]}) {
+ const {t: l} = useLingui()
+
+ return (
+ <>
+ Example
+
+ {foo.map((foo, index) => (
+
+ {index}
+ {foo}
+
+ ))}
+
+ >
+ );
+}
+```
+
### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
@@ -263,23 +313,29 @@ function MyFeature() {
{/* Typically the inner part is in its own component */}
- {/* Native-only drag handle */}
-
-
- Title
-
-
- Dialog content here
-
-
- {/* Web-only X button in top left */}
-
+
>
)
}
+
+function DialogInner() {
+ return (
+ <>
+ {/* Native-only drag handle */}
+
+
+ Title
+
+ Dialog content here
+
+ {/* Web-only X button in top left */}
+
+ >
+ )
+}
```
### Menu Component
@@ -345,6 +401,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
```
**Button Props:**
+
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
@@ -384,39 +441,66 @@ import * as TextField from '#/components/forms/TextField'
## Internationalization (i18n)
-All user-facing strings must be wrapped for translation using Lingui.
+All user-facing strings must be wrapped for translation using Lingui. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb.
+
+Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``.
+
+Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one – two` over `one—two`.
```tsx
-import {msg, plural} from '@lingui/core/macro'
-import {Trans} from '@lingui/react/macro'
-import {useLingui} from '@lingui/react'
+import {plural} from '@lingui/core/macro'
+import {Trans, useLingui} from '@lingui/react/macro'
function MyComponent() {
- const {_} = useLingui()
+ const {t: l} = useLingui()
- // Simple strings - use msg() with _() function
- const title = _(msg`Settings`)
- const errorMessage = _(msg`Something went wrong`)
+ // Simple strings - use the l macro
+ const title = l`Settings`
+ const errorMessage = l({
+ message: 'Something went wrong',
+ comment: 'Generic error message for unknown/unhandled errors.',
+ context: 'Toast',
+ })
// Strings with variables
- const greeting = _(msg`Hello, ${name}!`)
+ const greeting = l`Hello, ${name}!`
// Pluralization
- const countLabel = _(plural(count, {
+ const countLabel = plural(count, {
one: '# item',
other: '# items',
- }))
+ })
// JSX content - use Trans component
return (
- Welcome to Bluesky
+
+ Welcome to Bluesky, {name}!
+
)
}
```
+Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`.
+
+```tsx
+import {useLingui} from '@lingui/react/macro'
+
+function MyComponent() {
+ const {i18n} = useLingui()
+
+ const createdAt = new Date()
+
+ return i18n.date(createdAt, {
+ dateStyle: 'medium',
+ timeStyle: 'medium',
+ })
+}
+```
+
**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
@@ -473,7 +557,7 @@ export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: async (data) => {
+ mutationFn: async data => {
// Update logic
},
onSuccess: (_, variables) => {
@@ -481,7 +565,7 @@ export function useProfileMutation() {
queryKey: createProfileQueryKey({did: variables.did}),
})
},
- onError: (error) => {
+ onError: error => {
if (isNetworkError(error)) {
// don't log, but inform user
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
@@ -490,7 +574,7 @@ export function useProfileMutation() {
// Log unexpected errors to Sentry
logger.error('Error updating profile', {safeMessage: error})
}
- }
+ },
})
}
@@ -505,21 +589,25 @@ export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial) => {
- queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => {
- if (!oldData) return oldData
- return {...oldData, ...data}
- })
+ queryClient.setQueryData(
+ createProfileQueryKey({did: data.did}),
+ oldData => {
+ if (!oldData) return oldData
+ return {...oldData, ...data}
+ },
+ )
}
}
```
**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
+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:
@@ -565,12 +653,7 @@ function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
- return (
-
- )
+ return
}
```
@@ -604,13 +687,9 @@ import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps
export function ProfileScreen({route, navigation}: Props) {
- const {name} = route.params // Type-safe params
+ const {name} = route.params // Type-safe params
- return (
-
- {/* Screen content */}
-
- )
+ return {/* Screen content */}
}
// Programmatic navigation
@@ -637,8 +716,9 @@ 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)
+
+- `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:
@@ -653,6 +733,7 @@ const storage = IS_NATIVE
```
Platform detection (for runtime logic, not imports):
+
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
@@ -687,13 +768,13 @@ Common pitfalls to avoid in this codebase:
// WRONG - causes bugs with state updates, navigation, opening other dialogs
const onConfirm = () => {
control.close()
- navigation.navigate('Home') // May race with dialog animation
+ navigation.navigate('Home') // May race with dialog animation
}
// WRONG - same problem
const onConfirm = () => {
control.close()
- otherDialogControl.open() // Will likely fail or cause visual glitches
+ otherDialogControl.open() // Will likely fail or cause visual glitches
}
// CORRECT - action runs after dialog fully closes
@@ -720,12 +801,13 @@ const onConfirm = () => {
```
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`.
+The Menu component on iOS specifically uses this pattern – see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs
@@ -748,10 +830,11 @@ Prefer `defaultValue` over `value` for TextInput on the old architecture:
### 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
+
+- `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.
@@ -772,6 +855,7 @@ const handlePress = () => {
```
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
@@ -779,7 +863,7 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
-2. **Translations**: Wrap ALL user-facing strings with `msg()` or ``
+2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or ``
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
@@ -793,14 +877,14 @@ Only use `useMemo`/`useCallback` when you have a specific reason, such as:
## 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` |
+| 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` |