Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

1# CLAUDE.md - Bluesky Social App Development Guide 2 3This document provides guidance for working effectively in the Bluesky Social app codebase. 4 5## Project Overview 6 7Bluesky 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 21yarn start # Start Expo dev server 22yarn web # Start web version 23yarn android # Run on Android 24yarn ios # Run on iOS 25 26# Testing & Quality 27yarn test # Run Jest tests 28yarn lint # Run ESLint 29yarn typecheck # Run TypeScript type checking 30 31# Internationalization 32# DO NOT run these commands - extraction and compilation are handled by CI 33yarn intl:extract # Extract translation strings (nightly CI job) 34yarn intl:compile # Compile translations for runtime (nightly CI job) 35 36# Build 37yarn build-web # Build web version 38yarn prebuild # Generate native projects 39``` 40 41## Project Structure 42 43``` 44src/ 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 64ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens. 65 66### Basic Usage 67 68```tsx 69import {atoms as a, useTheme} from '#/alf' 70 71function 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 88import {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 94const 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 101import {web, native, ios, android, platform} from '#/alf' 102 103const 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 113import {useBreakpoints} from '#/alf' 114 115const {gtPhone, gtMobile, gtTablet} = useBreakpoints() 116if (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 133Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. 134 135```tsx 136import * as Dialog from '#/components/Dialog' 137 138function 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 170Menus render as a dropdown on web and a bottom sheet dialog on native. 171 172```tsx 173import * as Menu from '#/components/Menu' 174 175function 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 206import {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 239import {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 252import * 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 270All user-facing strings must be wrapped for translation using Lingui. 271 272```tsx 273import {msg, Trans, plural} from '@lingui/macro' 274import {useLingui} from '@lingui/react' 275 276function 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 304yarn intl:extract # Extract new strings to locale files 305yarn 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 314import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' 315 316// Query key pattern 317const RQKEY_ROOT = 'profile' 318export const RQKEY = (did: string) => [RQKEY_ROOT, did] 319 320// Query hook 321export 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 336export 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 362STALE.SECONDS.FIFTEEN // 15 seconds 363STALE.MINUTES.ONE // 1 minute 364STALE.MINUTES.FIVE // 5 minutes 365STALE.HOURS.ONE // 1 hour 366STALE.INFINITY // Never stale 367``` 368 369**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these: 370 371```tsx 372export 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 387To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []` 388 389### Preferences (React Context) 390 391```tsx 392// Simple boolean preference pattern 393import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' 394 395function 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 411import {useSession, useAgent} from '#/state/session' 412 413function 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 428Navigation uses React Navigation with type-safe route parameters. 429 430```tsx 431// Screen component 432import {type NativeStackScreenProps} from '@react-navigation/native-stack' 433import {type CommonNavigatorParams} from '#/lib/routes/types' 434 435type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> 436 437export 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 448import {useNavigation} from '@react-navigation/native' 449 450const navigation = useNavigation() 451navigation.navigate('Profile', {name: 'alice.bsky.social'}) 452 453// Or use the navigate helper 454import {navigate} from '#/Navigation' 455navigate('Profile', {name: 'alice.bsky.social'}) 456``` 457 458## Platform-Specific Code 459 460Use file extensions for platform-specific implementations: 461 462``` 463Component.tsx # Shared/default 464Component.web.tsx # Web-only 465Component.native.tsx # iOS + Android 466Component.ios.tsx # iOS-only 467Component.android.tsx # Android-only 468``` 469 470Example 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 478import * as storage from '#/state/drafts/storage' 479 480// WRONG - don't use require() or conditional imports for platform files 481const storage = IS_NATIVE 482 ? require('#/state/drafts/storage') 483 : require('#/state/drafts/storage.web') 484``` 485 486Platform detection (for runtime logic, not imports): 487```tsx 488import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env' 489 490if (IS_NATIVE) { 491 // Native-specific logic 492} 493``` 494 495## Import Aliases 496 497Always use the `#/` alias for absolute imports: 498 499```tsx 500// Good 501import {useSession} from '#/state/session' 502import {atoms as a, useTheme} from '#/alf' 503import {Button} from '#/components/Button' 504 505// Avoid 506import {useSession} from '../../../state/session' 507``` 508 509## Footguns 510 511Common 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 519const onConfirm = () => { 520 control.close() 521 navigation.navigate('Home') // May race with dialog animation 522} 523 524// WRONG - same problem 525const onConfirm = () => { 526 control.close() 527 otherDialogControl.open() // Will likely fail or cause visual glitches 528} 529 530// CORRECT - action runs after dialog fully closes 531const onConfirm = () => { 532 control.close(() => { 533 navigation.navigate('Home') 534 }) 535} 536 537// CORRECT - opening another dialog after close 538const onConfirm = () => { 539 control.close(() => { 540 otherDialogControl.open() 541 }) 542} 543 544// CORRECT - state updates after close 545const onConfirm = () => { 546 control.close(() => { 547 setSomeState(newValue) 548 onCallback?.() 549 }) 550} 551``` 552 553This 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 559The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`. 560 561### Controlled vs Uncontrolled Inputs 562 563Prefer `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 581Some 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 587Always test on multiple platforms when using these components. 588 589### React Compiler is Enabled 590 591This 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 595const handlePress = useCallback(() => { 596 doSomething() 597}, [doSomething]) 598 599// JUST WRITE THIS 600const handlePress = () => { 601 doSomething() 602} 603``` 604 605Only 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 6111. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful 612 6132. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>` 614 6153. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles 616 6174. **State**: Use TanStack Query for server state, React Context for UI preferences 618 6195. **Components**: Check if a component exists in `#/components/` before creating new ones 620 6216. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens 622 6237. **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` |