Bluesky app fork with some witchin' additions 💫

Add comprehensive CLAUDE.md development guide (#9666)

* Add comprehensive CLAUDE.md development guide

Document the codebase architecture, styling system (ALF), component patterns
(Dialog, Menu, Button), i18n with Lingui, state management with TanStack Query,
and navigation patterns to help Claude work effectively in this codebase.

* Add footguns section to CLAUDE.md

Document critical pitfalls including:
- Dialog close callback pattern (control.close(() => ...)) for avoiding
race conditions with navigation, state updates, and opening other dialogs
- Controlled vs uncontrolled input guidance
- Platform-specific component behavior differences

* Add React Compiler note to footguns section

Document that useMemo/useCallback are unnecessary since React Compiler
handles memoization automatically. Only use them for specific cases like
effect dependencies or non-React library interop.

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by samuel.fm

Claude and committed by
GitHub
af926108 ca905eb3

+591
+591
CLAUDE.md
··· 1 + # CLAUDE.md - Bluesky Social App Development Guide 2 + 3 + This document provides guidance for working effectively in the Bluesky Social app codebase. 4 + 5 + ## Project Overview 6 + 7 + 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. 8 + 9 + **Tech Stack:** 10 + - React Native 0.81 with Expo 54 11 + - TypeScript 12 + - React Navigation for routing 13 + - TanStack Query (React Query) for data fetching 14 + - Lingui for internationalization 15 + - Custom design system called ALF (Application Layout Framework) 16 + 17 + ## Essential Commands 18 + 19 + ```bash 20 + # Development 21 + yarn start # Start Expo dev server 22 + yarn web # Start web version 23 + yarn android # Run on Android 24 + yarn ios # Run on iOS 25 + 26 + # Testing & Quality 27 + yarn test # Run Jest tests 28 + yarn lint # Run ESLint 29 + yarn typecheck # Run TypeScript type checking 30 + 31 + # Internationalization 32 + yarn intl:extract # Extract translation strings 33 + yarn intl:compile # Compile translations for runtime 34 + 35 + # Build 36 + yarn build-web # Build web version 37 + yarn prebuild # Generate native projects 38 + ``` 39 + 40 + ## Project Structure 41 + 42 + ``` 43 + src/ 44 + ├── alf/ # Design system (ALF) - themes, atoms, tokens 45 + ├── components/ # Shared UI components (Button, Dialog, Menu, etc.) 46 + ├── screens/ # Full-page screen components (newer pattern) 47 + ├── view/ 48 + │ ├── screens/ # Full-page screens (legacy location) 49 + │ ├── com/ # Reusable view components 50 + │ └── shell/ # App shell (navigation bars, tabs) 51 + ├── state/ 52 + │ ├── queries/ # TanStack Query hooks 53 + │ ├── preferences/ # User preferences (React Context) 54 + │ ├── session/ # Authentication state 55 + │ └── persisted/ # Persistent storage layer 56 + ├── lib/ # Utilities, constants, helpers 57 + ├── locale/ # i18n configuration and language files 58 + └── Navigation.tsx # Main navigation configuration 59 + ``` 60 + 61 + ## Styling System (ALF) 62 + 63 + ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens. 64 + 65 + ### Basic Usage 66 + 67 + ```tsx 68 + import {atoms as a, useTheme} from '#/alf' 69 + 70 + function MyComponent() { 71 + const t = useTheme() 72 + 73 + return ( 74 + <View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}> 75 + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 76 + Hello 77 + </Text> 78 + </View> 79 + ) 80 + } 81 + ``` 82 + 83 + ### Key Concepts 84 + 85 + **Static Atoms** - Theme-independent styles imported from `atoms`: 86 + ```tsx 87 + import {atoms as a} from '#/alf' 88 + // a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc. 89 + ``` 90 + 91 + **Theme Atoms** - Theme-dependent colors from `useTheme()`: 92 + ```tsx 93 + const t = useTheme() 94 + // t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc. 95 + // t.palette.primary_500, t.palette.negative_400, etc. 96 + ``` 97 + 98 + **Platform Utilities** - For platform-specific styles: 99 + ```tsx 100 + import {web, native, ios, android, platform} from '#/alf' 101 + 102 + const styles = [ 103 + a.p_md, 104 + web({cursor: 'pointer'}), 105 + native({paddingBottom: 20}), 106 + platform({ios: {...}, android: {...}, web: {...}}), 107 + ] 108 + ``` 109 + 110 + **Breakpoints** - Responsive design: 111 + ```tsx 112 + import {useBreakpoints} from '#/alf' 113 + 114 + const {gtPhone, gtMobile, gtTablet} = useBreakpoints() 115 + if (gtMobile) { 116 + // Tablet or desktop layout 117 + } 118 + ``` 119 + 120 + ### Naming Conventions 121 + 122 + - Spacing: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `xxl` (t-shirt sizes) 123 + - Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl` 124 + - Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl` 125 + - Flex: `flex_row`, `flex_1`, `align_center`, `justify_between` 126 + - Borders: `border`, `border_t`, `rounded_md`, `rounded_full` 127 + 128 + ## Component Patterns 129 + 130 + ### Dialog Component 131 + 132 + Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. 133 + 134 + ```tsx 135 + import * as Dialog from '#/components/Dialog' 136 + 137 + function MyFeature() { 138 + const control = Dialog.useDialogControl() 139 + 140 + return ( 141 + <> 142 + <Button label="Open" onPress={control.open}> 143 + <ButtonText>Open Dialog</ButtonText> 144 + </Button> 145 + 146 + <Dialog.Outer control={control}> 147 + <Dialog.Handle /> {/* Native drag handle */} 148 + <Dialog.ScrollableInner label={_(msg`My Dialog`)}> 149 + <Dialog.Header> 150 + <Dialog.HeaderText>Title</Dialog.HeaderText> 151 + </Dialog.Header> 152 + 153 + <Text>Dialog content here</Text> 154 + 155 + <Button label="Close" onPress={() => control.close()}> 156 + <ButtonText>Close</ButtonText> 157 + </Button> 158 + </Dialog.ScrollableInner> 159 + </Dialog.Outer> 160 + </> 161 + ) 162 + } 163 + ``` 164 + 165 + ### Menu Component 166 + 167 + Menus render as a dropdown on web and a bottom sheet dialog on native. 168 + 169 + ```tsx 170 + import * as Menu from '#/components/Menu' 171 + 172 + function MyMenu() { 173 + return ( 174 + <Menu.Root> 175 + <Menu.Trigger label="Open menu"> 176 + {({props}) => ( 177 + <Button {...props} label="Menu"> 178 + <ButtonIcon icon={DotsHorizontal} /> 179 + </Button> 180 + )} 181 + </Menu.Trigger> 182 + 183 + <Menu.Outer> 184 + <Menu.Group> 185 + <Menu.Item label="Edit" onPress={handleEdit}> 186 + <Menu.ItemIcon icon={Pencil} /> 187 + <Menu.ItemText>Edit</Menu.ItemText> 188 + </Menu.Item> 189 + <Menu.Item label="Delete" onPress={handleDelete}> 190 + <Menu.ItemIcon icon={Trash} /> 191 + <Menu.ItemText>Delete</Menu.ItemText> 192 + </Menu.Item> 193 + </Menu.Group> 194 + </Menu.Outer> 195 + </Menu.Root> 196 + ) 197 + } 198 + ``` 199 + 200 + ### Button Component 201 + 202 + ```tsx 203 + import {Button, ButtonText, ButtonIcon} from '#/components/Button' 204 + 205 + // Solid primary button (most common) 206 + <Button label="Save" onPress={handleSave} color="primary" size="large"> 207 + <ButtonText>Save</ButtonText> 208 + </Button> 209 + 210 + // With icon 211 + <Button label="Share" onPress={handleShare} color="secondary" size="small"> 212 + <ButtonIcon icon={Share} /> 213 + <ButtonText>Share</ButtonText> 214 + </Button> 215 + 216 + // Icon-only button 217 + <Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round"> 218 + <ButtonIcon icon={X} /> 219 + </Button> 220 + 221 + // Ghost variant (deprecated - use color prop) 222 + <Button label="Cancel" variant="ghost" color="secondary" size="small"> 223 + <ButtonText>Cancel</ButtonText> 224 + </Button> 225 + ``` 226 + 227 + **Button Props:** 228 + - `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` 229 + - `size`: `'tiny'` | `'small'` | `'large'` 230 + - `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` 231 + - `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`) 232 + 233 + ### Typography 234 + 235 + ```tsx 236 + import {Text, H1, H2, P} from '#/components/Typography' 237 + 238 + <H1 style={[a.text_xl, a.font_bold]}>Heading</H1> 239 + <P>Paragraph text with default styling.</P> 240 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text> 241 + 242 + // For text with emoji, add the emoji prop 243 + <Text emoji>Hello! 👋</Text> 244 + ``` 245 + 246 + ### TextField 247 + 248 + ```tsx 249 + import * as TextField from '#/components/forms/TextField' 250 + 251 + <TextField.LabelText>Email</TextField.LabelText> 252 + <TextField.Root> 253 + <TextField.Icon icon={AtSign} /> 254 + <TextField.Input 255 + label="Email address" 256 + placeholder="you@example.com" 257 + defaultValue={email} 258 + onChangeText={setEmail} 259 + keyboardType="email-address" 260 + autoCapitalize="none" 261 + /> 262 + </TextField.Root> 263 + ``` 264 + 265 + ## Internationalization (i18n) 266 + 267 + All user-facing strings must be wrapped for translation using Lingui. 268 + 269 + ```tsx 270 + import {msg, Trans, plural} from '@lingui/macro' 271 + import {useLingui} from '@lingui/react' 272 + 273 + function MyComponent() { 274 + const {_} = useLingui() 275 + 276 + // Simple strings - use msg() with _() function 277 + const title = _(msg`Settings`) 278 + const errorMessage = _(msg`Something went wrong`) 279 + 280 + // Strings with variables 281 + const greeting = _(msg`Hello, ${name}!`) 282 + 283 + // Pluralization 284 + const countLabel = _(plural(count, { 285 + one: '# item', 286 + other: '# items', 287 + })) 288 + 289 + // JSX content - use Trans component 290 + return ( 291 + <Text> 292 + <Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans> 293 + </Text> 294 + ) 295 + } 296 + ``` 297 + 298 + **Commands:** 299 + ```bash 300 + yarn intl:extract # Extract new strings to locale files 301 + yarn intl:compile # Compile for runtime (required after changes) 302 + ``` 303 + 304 + ## State Management 305 + 306 + ### TanStack Query (Data Fetching) 307 + 308 + ```tsx 309 + // src/state/queries/profile.ts 310 + import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' 311 + 312 + // Query key pattern 313 + const RQKEY_ROOT = 'profile' 314 + export const RQKEY = (did: string) => [RQKEY_ROOT, did] 315 + 316 + // Query hook 317 + export function useProfileQuery({did}: {did: string}) { 318 + const agent = useAgent() 319 + 320 + return useQuery({ 321 + queryKey: RQKEY(did), 322 + queryFn: async () => { 323 + const res = await agent.getProfile({actor: did}) 324 + return res.data 325 + }, 326 + staleTime: STALE.MINUTES.FIVE, 327 + enabled: !!did, 328 + }) 329 + } 330 + 331 + // Mutation hook 332 + export function useUpdateProfile() { 333 + const queryClient = useQueryClient() 334 + 335 + return useMutation({ 336 + mutationFn: async (data) => { 337 + // Update logic 338 + }, 339 + onSuccess: (_, variables) => { 340 + queryClient.invalidateQueries({queryKey: RQKEY(variables.did)}) 341 + }, 342 + }) 343 + } 344 + ``` 345 + 346 + **Stale Time Constants** (from `src/state/queries/index.ts`): 347 + ```tsx 348 + STALE.SECONDS.FIFTEEN // 15 seconds 349 + STALE.MINUTES.ONE // 1 minute 350 + STALE.MINUTES.FIVE // 5 minutes 351 + STALE.HOURS.ONE // 1 hour 352 + STALE.INFINITY // Never stale 353 + ``` 354 + 355 + ### Preferences (React Context) 356 + 357 + ```tsx 358 + // Simple boolean preference pattern 359 + import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' 360 + 361 + function SettingsScreen() { 362 + const autoplayDisabled = useAutoplayDisabled() 363 + const setAutoplayDisabled = useSetAutoplayDisabled() 364 + 365 + return ( 366 + <Toggle 367 + value={autoplayDisabled} 368 + onValueChange={setAutoplayDisabled} 369 + /> 370 + ) 371 + } 372 + ``` 373 + 374 + ### Session State 375 + 376 + ```tsx 377 + import {useSession, useAgent} from '#/state/session' 378 + 379 + function MyComponent() { 380 + const {hasSession, currentAccount} = useSession() 381 + const agent = useAgent() 382 + 383 + if (!hasSession) { 384 + return <LoginPrompt /> 385 + } 386 + 387 + // Use agent for API calls 388 + const response = await agent.getProfile({actor: currentAccount.did}) 389 + } 390 + ``` 391 + 392 + ## Navigation 393 + 394 + Navigation uses React Navigation with type-safe route parameters. 395 + 396 + ```tsx 397 + // Screen component 398 + import {type NativeStackScreenProps} from '@react-navigation/native-stack' 399 + import {type CommonNavigatorParams} from '#/lib/routes/types' 400 + 401 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> 402 + 403 + export function ProfileScreen({route, navigation}: Props) { 404 + const {name} = route.params // Type-safe params 405 + 406 + return ( 407 + <Layout.Screen> 408 + {/* Screen content */} 409 + </Layout.Screen> 410 + ) 411 + } 412 + 413 + // Programmatic navigation 414 + import {useNavigation} from '@react-navigation/native' 415 + 416 + const navigation = useNavigation() 417 + navigation.navigate('Profile', {name: 'alice.bsky.social'}) 418 + 419 + // Or use the navigate helper 420 + import {navigate} from '#/Navigation' 421 + navigate('Profile', {name: 'alice.bsky.social'}) 422 + ``` 423 + 424 + ## Platform-Specific Code 425 + 426 + Use file extensions for platform-specific implementations: 427 + 428 + ``` 429 + Component.tsx # Shared/default 430 + Component.web.tsx # Web-only 431 + Component.native.tsx # iOS + Android 432 + Component.ios.tsx # iOS-only 433 + Component.android.tsx # Android-only 434 + ``` 435 + 436 + Example from Dialog: 437 + - `src/components/Dialog/index.tsx` - Native (uses BottomSheet) 438 + - `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives) 439 + 440 + Platform detection: 441 + ```tsx 442 + import {isWeb, isNative, isIOS, isAndroid} from '#/platform/detection' 443 + 444 + if (isNative) { 445 + // Native-specific logic 446 + } 447 + ``` 448 + 449 + ## Import Aliases 450 + 451 + Always use the `#/` alias for absolute imports: 452 + 453 + ```tsx 454 + // Good 455 + import {useSession} from '#/state/session' 456 + import {atoms as a, useTheme} from '#/alf' 457 + import {Button} from '#/components/Button' 458 + 459 + // Avoid 460 + import {useSession} from '../../../state/session' 461 + ``` 462 + 463 + ## Footguns 464 + 465 + Common pitfalls to avoid in this codebase: 466 + 467 + ### Dialog Close Callback (Critical) 468 + 469 + **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. 470 + 471 + ```tsx 472 + // WRONG - causes bugs with state updates, navigation, opening other dialogs 473 + const onConfirm = () => { 474 + control.close() 475 + navigation.navigate('Home') // May race with dialog animation 476 + } 477 + 478 + // WRONG - same problem 479 + const onConfirm = () => { 480 + control.close() 481 + otherDialogControl.open() // Will likely fail or cause visual glitches 482 + } 483 + 484 + // CORRECT - action runs after dialog fully closes 485 + const onConfirm = () => { 486 + control.close(() => { 487 + navigation.navigate('Home') 488 + }) 489 + } 490 + 491 + // CORRECT - opening another dialog after close 492 + const onConfirm = () => { 493 + control.close(() => { 494 + otherDialogControl.open() 495 + }) 496 + } 497 + 498 + // CORRECT - state updates after close 499 + const onConfirm = () => { 500 + control.close(() => { 501 + setSomeState(newValue) 502 + onCallback?.() 503 + }) 504 + } 505 + ``` 506 + 507 + This applies to: 508 + - Navigation (`navigation.navigate()`, `navigation.push()`) 509 + - Opening other dialogs or menus 510 + - State updates that affect UI (`setState`, `queryClient.invalidateQueries`) 511 + - Callbacks passed from parent components 512 + 513 + The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`. 514 + 515 + ### Controlled vs Uncontrolled Inputs 516 + 517 + Prefer `defaultValue` over `value` for TextInput on the old architecture: 518 + 519 + ```tsx 520 + // Preferred - uncontrolled 521 + <TextField.Input 522 + defaultValue={initialEmail} 523 + onChangeText={setEmail} 524 + /> 525 + 526 + // Avoid when possible - controlled (can cause performance issues) 527 + <TextField.Input 528 + value={email} 529 + onChangeText={setEmail} 530 + /> 531 + ``` 532 + 533 + ### Platform-Specific Behavior 534 + 535 + Some components behave differently across platforms: 536 + - `Dialog.Handle` - Only renders on native (drag handle for bottom sheet) 537 + - `Dialog.Close` - Only renders on web (X button) 538 + - `Menu.Divider` - Only renders on web 539 + - `Menu.ContainerItem` - Only works on native 540 + 541 + Always test on multiple platforms when using these components. 542 + 543 + ### React Compiler is Enabled 544 + 545 + This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically. 546 + 547 + ```tsx 548 + // UNNECESSARY - React Compiler handles this 549 + const handlePress = useCallback(() => { 550 + doSomething() 551 + }, [doSomething]) 552 + 553 + // JUST WRITE THIS 554 + const handlePress = () => { 555 + doSomething() 556 + } 557 + ``` 558 + 559 + Only use `useMemo`/`useCallback` when you have a specific reason, such as: 560 + - The value is immediately used in an effect's dependency array 561 + - You're passing a callback to a non-React library that needs referential stability 562 + 563 + ## Best Practices 564 + 565 + 1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful 566 + 567 + 2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>` 568 + 569 + 3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles 570 + 571 + 4. **State**: Use TanStack Query for server state, React Context for UI preferences 572 + 573 + 5. **Components**: Check if a component exists in `#/components/` before creating new ones 574 + 575 + 6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens 576 + 577 + 7. **Testing**: Components should have `testID` props for E2E testing 578 + 579 + ## Key Files Reference 580 + 581 + | Purpose | Location | 582 + |---------|----------| 583 + | Theme definitions | `src/alf/themes.ts` | 584 + | Design tokens | `src/alf/tokens.ts` | 585 + | Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) | 586 + | Navigation config | `src/Navigation.tsx` | 587 + | Route definitions | `src/routes.ts` | 588 + | Route types | `src/lib/routes/types.ts` | 589 + | Query hooks | `src/state/queries/*.ts` | 590 + | Session state | `src/state/session/index.tsx` | 591 + | i18n setup | `src/locale/i18n.ts` |