Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
at main 755 lines 23 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├── features/ # Macro-features that bridge components/screens 50├── view/ 51│ ├── screens/ # Full-page screens (legacy location) 52│ ├── com/ # Reusable view components 53│ └── shell/ # App shell (navigation bars, tabs) 54├── state/ 55│ ├── queries/ # TanStack Query hooks 56│ ├── preferences/ # User preferences (React Context) 57│ ├── session/ # Authentication state 58│ └── persisted/ # Persistent storage layer 59├── lib/ # Utilities, constants, helpers 60├── locale/ # i18n configuration and language files 61└── Navigation.tsx # Main navigation configuration 62``` 63 64### Project Structure in Depth 65 66When building new things, follow these guidelines for where to put code. 67 68#### Components vs Screens vs Features 69 70**Components** are reusable UI elements that are not full screens. Should be 71platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put 72these in `/components` if they are shared across screens. 73 74**Screens** are full-page components that represent a route in the app. They 75often contain multiple components and handle layout for a page. New screens 76should go in `/screens` (not `/view/screens`) to encourage better organization 77and separation from legacy code. 78 79For complex screens that have specific components or data needs that _are not 80shared by other screens_, we encourage subdirectoreis within `/screens/<name>` 81e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and 82`/screens/ProfileScreen/components/`. 83 84**Features** are higher-level modules that may include context, data fetching, 85components, and utilities related to a specific feature e.g. 86`/features/liveNow`. They don't neatly fit into components or screens and often 87span multiple screens. This is an optional pattern for organizing complex 88features. 89 90#### Legacy Directories 91 92For the most part, avoid writing new files into the `/view` directory and 93subdirectories. This is the older pattern for organizing screens and components, 94and it has become a bit disorganized over time. New development should go into 95`/screens`, `/components`, and `/features`. 96 97#### State 98 99The `/state` directory is where we've historically put all our data fetching and 100state management logic. This is perfectly fine, but for new features, consider 101organizing state logic closer to the components that use it, either within a 102feature directory or co-located with a screen. The key is to keep related code 103together and avoid having "god files" with too much unrelated logic. 104 105#### Lib 106 107The `/lib` directory is for utilities and helpers that don't fit into other 108categories. This can include things like API clients, formatting functions, 109constants, and other shared logic. 110 111#### Top Level Directories 112 113Avoid writing new top-level subdirectories within `/src`. We've done this for a 114few things in the past that, but we have stronger patterns now. Examples: 115`/logger` should probably have been written into `/lib`. And `ageAssurance` is 116better classified within `/features`. We will probably migrate these things 117eventually. 118 119### File and Directory Naming Conventions 120 121Typically JS style for variables, functions, etc. We use ProudCamelCase for 122components, and camelCase directories and files. 123 124When organizing new code, consider if it fits into a single file, or if it 125should be broken down into multiple files. For "macro" component cases, or 126things that live in `/features` or `/screens`, we often follow a pattern of 127having an `index.tsx` for the main component, and then co-locating related 128components, hooks, and utilities in the same directory. For example: 129 130``` 131src 132├── screens/ 133│ ├── ProfileScreen/ 134│ │ ├── index.tsx # Main screen component 135│ │ ├── components/ # Sub-components used only by this screen 136``` 137 138Similar patterns can be found in `/features` and `/components`. The idea here is 139to keep related code together and make it easier to navigate. 140 141You should ask yourself: if someone new was looking for the code related to this 142feature or screen, where would they expect to find it? Organizing code in a way 143that matches developer expectations can make the codebase much more 144approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is 145easier to understand than having it scattered across multiple directories. 146 147No need to go overboard with this. If a component or feature fits into a single 148file, there's no reason to have a `/Component/index.tsx` file when it could just 149be `/Component.tsx`. Use your judgment based on the complexity and amount of 150related code. 151 152#### Platform Specific Files 153 154We have conflicting patterns in the app for this. The preferred approach is to 155group platform-specific files into a directory as much as possible. For example, 156rather than having `Component.tsx`, `Component.web.tsx`, and 157`Component.native.tsx` in the same directory, we prefer to have a `Component/` 158directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps 159related code together and gives us a better visual cue that there are probably 160other files contained within this "macro" feature, whereas `Component.tsx` on 161its own looks more like a single component file. 162 163### Documentation and Tests Within Features 164 165For larger features or components, it's helpful to include a README.md file 166within the directory that explains the purpose of the feature, how it works, and 167any important implementation details. The `/Component/index.tsx` pattern lends 168itself well to this, since the `index.tsx` can be the main component file, and 169the `README.md` can provide documentation for the whole feature. This is 170optional, but can be a nice way to keep documentation close to the code it 171describes. 172 173Similarly, if there are tests that are specific to a component or feature, it 174can be helpful to include them in the same directory, either as 175`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything 176related to the component or feature in one place and makes it easier to find and 177maintain tests. 178 179## Styling System (ALF) 180 181ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens. 182 183### Basic Usage 184 185```tsx 186import {atoms as a, useTheme} from '#/alf' 187 188function MyComponent() { 189 const t = useTheme() 190 191 return ( 192 <View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}> 193 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 194 Hello 195 </Text> 196 </View> 197 ) 198} 199``` 200 201### Key Concepts 202 203**Static Atoms** - Theme-independent styles imported from `atoms`: 204```tsx 205import {atoms as a} from '#/alf' 206// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc. 207``` 208 209**Theme Atoms** - Theme-dependent colors from `useTheme()`: 210```tsx 211const t = useTheme() 212// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc. 213// t.palette.primary_500, t.palette.negative_400, etc. 214``` 215 216**Platform Utilities** - For platform-specific styles: 217```tsx 218import {web, native, ios, android, platform} from '#/alf' 219 220const styles = [ 221 a.p_md, 222 web({cursor: 'pointer'}), 223 native({paddingBottom: 20}), 224 platform({ios: {...}, android: {...}, web: {...}}), 225] 226``` 227 228**Breakpoints** - Responsive design: 229```tsx 230import {useBreakpoints} from '#/alf' 231 232const {gtPhone, gtMobile, gtTablet} = useBreakpoints() 233if (gtMobile) { 234 // Tablet or desktop layout 235} 236``` 237 238### Naming Conventions 239 240- Spacing: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (t-shirt sizes) 241- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl` 242- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl` 243- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between` 244- Borders: `border`, `border_t`, `rounded_md`, `rounded_full` 245 246## Component Patterns 247 248### Dialog Component 249 250Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. 251 252```tsx 253import * as Dialog from '#/components/Dialog' 254 255function MyFeature() { 256 const control = Dialog.useDialogControl() 257 258 return ( 259 <> 260 <Button label="Open" onPress={control.open}> 261 <ButtonText>Open Dialog</ButtonText> 262 </Button> 263 264 <Dialog.Outer control={control}> 265 {/* Typically the inner part is in its own component */} 266 <Dialog.Handle /> {/* Native-only drag handle */} 267 <Dialog.ScrollableInner label={_(msg`My Dialog`)}> 268 <Dialog.Header> 269 <Dialog.HeaderText>Title</Dialog.HeaderText> 270 </Dialog.Header> 271 272 <Text>Dialog content here</Text> 273 274 <Button label="Done" onPress={() => control.close()}> 275 <ButtonText>Done</ButtonText> 276 </Button> 277 <Dialog.Close /> {/* Web-only X button in top left */} 278 </Dialog.ScrollableInner> 279 </Dialog.Outer> 280 </> 281 ) 282} 283``` 284 285### Menu Component 286 287Menus render as a dropdown on web and a bottom sheet dialog on native. 288 289```tsx 290import * as Menu from '#/components/Menu' 291 292function MyMenu() { 293 return ( 294 <Menu.Root> 295 <Menu.Trigger label="Open menu"> 296 {({props}) => ( 297 <Button {...props} label="Menu"> 298 <ButtonIcon icon={DotsHorizontal} /> 299 </Button> 300 )} 301 </Menu.Trigger> 302 303 <Menu.Outer> 304 <Menu.Group> 305 <Menu.Item label="Edit" onPress={handleEdit}> 306 <Menu.ItemIcon icon={Pencil} /> 307 <Menu.ItemText>Edit</Menu.ItemText> 308 </Menu.Item> 309 <Menu.Item label="Delete" onPress={handleDelete}> 310 <Menu.ItemIcon icon={Trash} /> 311 <Menu.ItemText>Delete</Menu.ItemText> 312 </Menu.Item> 313 </Menu.Group> 314 </Menu.Outer> 315 </Menu.Root> 316 ) 317} 318``` 319 320### Button Component 321 322```tsx 323import {Button, ButtonText, ButtonIcon} from '#/components/Button' 324 325// Solid primary button (most common) 326<Button label="Save" onPress={handleSave} color="primary" size="large"> 327 <ButtonText>Save</ButtonText> 328</Button> 329 330// With icon 331<Button label="Share" onPress={handleShare} color="secondary" size="small"> 332 <ButtonIcon icon={Share} /> 333 <ButtonText>Share</ButtonText> 334</Button> 335 336// Icon-only button 337<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round"> 338 <ButtonIcon icon={XIcon} /> 339</Button> 340 341// Ghost variant (deprecated - use color prop) 342<Button label="Cancel" variant="ghost" color="secondary" size="small"> 343 <ButtonText>Cancel</ButtonText> 344</Button> 345``` 346 347**Button Props:** 348- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'` 349- `size`: `'tiny'` | `'small'` | `'large'` 350- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` 351- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`) 352 353### Typography 354 355```tsx 356import {Text, H1, H2, P} from '#/components/Typography' 357 358<H1 style={[a.text_xl, a.font_bold]}>Heading</H1> 359<P>Paragraph text with default styling.</P> 360<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text> 361 362// For text with emoji, add the emoji prop 363<Text emoji>Hello! 👋</Text> 364``` 365 366### TextField 367 368```tsx 369import * as TextField from '#/components/forms/TextField' 370 371<TextField.LabelText>Email</TextField.LabelText> 372<TextField.Root> 373 <TextField.Icon icon={AtSign} /> 374 <TextField.Input 375 label="Email address" 376 placeholder="you@example.com" 377 defaultValue={email} 378 onChangeText={setEmail} 379 keyboardType="email-address" 380 autoCapitalize="none" 381 /> 382</TextField.Root> 383``` 384 385## Internationalization (i18n) 386 387All user-facing strings must be wrapped for translation using Lingui. 388 389```tsx 390import {msg, plural} from '@lingui/core/macro' 391import {Trans} from '@lingui/react/macro' 392import {useLingui} from '@lingui/react' 393 394function MyComponent() { 395 const {_} = useLingui() 396 397 // Simple strings - use msg() with _() function 398 const title = _(msg`Settings`) 399 const errorMessage = _(msg`Something went wrong`) 400 401 // Strings with variables 402 const greeting = _(msg`Hello, ${name}!`) 403 404 // Pluralization 405 const countLabel = _(plural(count, { 406 one: '# item', 407 other: '# items', 408 })) 409 410 // JSX content - use Trans component 411 return ( 412 <Text> 413 <Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans> 414 </Text> 415 ) 416} 417``` 418 419**Commands:** 420```bash 421# DO NOT run these commands - extraction and compilation are handled by a nightly CI job 422yarn intl:extract # Extract new strings to locale files 423yarn intl:compile # Compile translations for runtime 424``` 425 426## State Management 427 428### TanStack Query (Data Fetching) 429 430```tsx 431// src/state/queries/profile.ts 432import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' 433 434// Query key pattern 435const RQKEY_ROOT = 'profile' 436export const RQKEY = (did: string) => [RQKEY_ROOT, did] 437 438// Query hook 439export function useProfileQuery({did}: {did: string}) { 440 const agent = useAgent() 441 442 return useQuery({ 443 queryKey: RQKEY(did), 444 queryFn: async () => { 445 const res = await agent.getProfile({actor: did}) 446 return res.data 447 }, 448 staleTime: STALE.MINUTES.FIVE, 449 enabled: !!did, 450 }) 451} 452 453// Mutation hook 454export function useUpdateProfile() { 455 const queryClient = useQueryClient() 456 457 return useMutation({ 458 mutationFn: async (data) => { 459 // Update logic 460 }, 461 onSuccess: (_, variables) => { 462 queryClient.invalidateQueries({queryKey: RQKEY(variables.did)}) 463 }, 464 onError: (error) => { 465 if (isNetworkError(error)) { 466 // don't log, but inform user 467 } else if (error instanceof AppBskyExampleProcedure.ExampleError) { 468 // XRPC APIs often have typed errors, allows nicer handling 469 } else { 470 // Log unexpected errors to Sentry 471 logger.error('Error updating profile', {safeMessage: error}) 472 } 473 } 474 }) 475} 476``` 477 478**Stale Time Constants** (from `src/state/queries/index.ts`): 479```tsx 480STALE.SECONDS.FIFTEEN // 15 seconds 481STALE.MINUTES.ONE // 1 minute 482STALE.MINUTES.FIVE // 5 minutes 483STALE.HOURS.ONE // 1 hour 484STALE.INFINITY // Never stale 485``` 486 487**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these: 488 489```tsx 490export function useDraftsQuery() { 491 const agent = useAgent() 492 493 return useInfiniteQuery({ 494 queryKey: ['drafts'], 495 queryFn: async ({pageParam}) => { 496 const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam}) 497 return res.data 498 }, 499 initialPageParam: undefined as string | undefined, 500 getNextPageParam: page => page.cursor, 501 }) 502} 503``` 504 505To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []` 506 507### Preferences (React Context) 508 509```tsx 510// Simple boolean preference pattern 511import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' 512 513function SettingsScreen() { 514 const autoplayDisabled = useAutoplayDisabled() 515 const setAutoplayDisabled = useSetAutoplayDisabled() 516 517 return ( 518 <Toggle 519 value={autoplayDisabled} 520 onValueChange={setAutoplayDisabled} 521 /> 522 ) 523} 524``` 525 526### Session State 527 528```tsx 529import {useSession, useAgent} from '#/state/session' 530 531function MyComponent() { 532 const {hasSession, currentAccount} = useSession() 533 const agent = useAgent() 534 535 if (!hasSession) { 536 return <LoginPrompt /> 537 } 538 539 // Use agent for API calls 540 const response = await agent.getProfile({actor: currentAccount.did}) 541} 542``` 543 544## Navigation 545 546Navigation uses React Navigation with type-safe route parameters. 547 548```tsx 549// Screen component 550import {type NativeStackScreenProps} from '@react-navigation/native-stack' 551import {type CommonNavigatorParams} from '#/lib/routes/types' 552 553type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> 554 555export function ProfileScreen({route, navigation}: Props) { 556 const {name} = route.params // Type-safe params 557 558 return ( 559 <Layout.Screen> 560 {/* Screen content */} 561 </Layout.Screen> 562 ) 563} 564 565// Programmatic navigation 566import {useNavigation} from '@react-navigation/native' 567 568const navigation = useNavigation() 569navigation.navigate('Profile', {name: 'alice.bsky.social'}) 570 571// Or use the navigate helper 572import {navigate} from '#/Navigation' 573navigate('Profile', {name: 'alice.bsky.social'}) 574``` 575 576## Platform-Specific Code 577 578Use file extensions for platform-specific implementations: 579 580``` 581Component.tsx # Shared/default 582Component.web.tsx # Web-only 583Component.native.tsx # iOS + Android 584Component.ios.tsx # iOS-only 585Component.android.tsx # Android-only 586``` 587 588Example from Dialog: 589- `src/components/Dialog/index.tsx` - Native (uses BottomSheet) 590- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives) 591 592**Important:** The bundler automatically resolves platform-specific files. Just import normally: 593 594```tsx 595// CORRECT - bundler picks storage.ts or storage.web.ts automatically 596import * as storage from '#/state/drafts/storage' 597 598// WRONG - don't use require() or conditional imports for platform files 599const storage = IS_NATIVE 600 ? require('#/state/drafts/storage') 601 : require('#/state/drafts/storage.web') 602``` 603 604Platform detection (for runtime logic, not imports): 605```tsx 606import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env' 607 608if (IS_NATIVE) { 609 // Native-specific logic 610} 611``` 612 613## Import Aliases 614 615Always use the `#/` alias for absolute imports: 616 617```tsx 618// Good 619import {useSession} from '#/state/session' 620import {atoms as a, useTheme} from '#/alf' 621import {Button} from '#/components/Button' 622 623// Avoid 624import {useSession} from '../../../state/session' 625``` 626 627## Footguns 628 629Common pitfalls to avoid in this codebase: 630 631### Dialog Close Callback (Critical) 632 633**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. 634 635```tsx 636// WRONG - causes bugs with state updates, navigation, opening other dialogs 637const onConfirm = () => { 638 control.close() 639 navigation.navigate('Home') // May race with dialog animation 640} 641 642// WRONG - same problem 643const onConfirm = () => { 644 control.close() 645 otherDialogControl.open() // Will likely fail or cause visual glitches 646} 647 648// CORRECT - action runs after dialog fully closes 649const onConfirm = () => { 650 control.close(() => { 651 navigation.navigate('Home') 652 }) 653} 654 655// CORRECT - opening another dialog after close 656const onConfirm = () => { 657 control.close(() => { 658 otherDialogControl.open() 659 }) 660} 661 662// CORRECT - state updates after close 663const onConfirm = () => { 664 control.close(() => { 665 setSomeState(newValue) 666 onCallback?.() 667 }) 668} 669``` 670 671This applies to: 672- Navigation (`navigation.navigate()`, `navigation.push()`) 673- Opening other dialogs or menus 674- State updates that affect UI (`setState`, `queryClient.invalidateQueries`) 675- Callbacks passed from parent components 676 677The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`. 678 679### Controlled vs Uncontrolled Inputs 680 681Prefer `defaultValue` over `value` for TextInput on the old architecture: 682 683```tsx 684// Preferred - uncontrolled 685<TextField.Input 686 defaultValue={initialEmail} 687 onChangeText={setEmail} 688/> 689 690// Avoid when possible - controlled (can cause performance issues) 691<TextField.Input 692 value={email} 693 onChangeText={setEmail} 694/> 695``` 696 697### Platform-Specific Behavior 698 699Some components behave differently across platforms: 700- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet) 701- `Dialog.Close` - Only renders on web (X button) 702- `Menu.Divider` - Only renders on web 703- `Menu.ContainerItem` - Only works on native 704 705Always test on multiple platforms when using these components. 706 707### React Compiler is Enabled 708 709This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically. 710 711```tsx 712// UNNECESSARY - React Compiler handles this 713const handlePress = useCallback(() => { 714 doSomething() 715}, [doSomething]) 716 717// JUST WRITE THIS 718const handlePress = () => { 719 doSomething() 720} 721``` 722 723Only use `useMemo`/`useCallback` when you have a specific reason, such as: 724- The value is immediately used in an effect's dependency array 725- You're passing a callback to a non-React library that needs referential stability 726 727## Best Practices 728 7291. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful 730 7312. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>` 732 7333. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles 734 7354. **State**: Use TanStack Query for server state, React Context for UI preferences 736 7375. **Components**: Check if a component exists in `#/components/` before creating new ones 738 7396. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens 740 7417. **Testing**: Components should have `testID` props for E2E testing 742 743## Key Files Reference 744 745| Purpose | Location | 746|---------|----------| 747| Theme definitions | `src/alf/themes.ts` | 748| Design tokens | `src/alf/tokens.ts` | 749| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) | 750| Navigation config | `src/Navigation.tsx` | 751| Route definitions | `src/routes.ts` | 752| Route types | `src/lib/routes/types.ts` | 753| Query hooks | `src/state/queries/*.ts` | 754| Session state | `src/state/session/index.tsx` | 755| i18n setup | `src/locale/i18n.ts` |