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