Mirror — see github.com/blacksky-algorithms/blacksky.community
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

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