Bluesky app fork with some witchin' additions 💫
at main 591 lines 16 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 27yarn test # Run Jest tests 28yarn lint # Run ESLint 29yarn typecheck # Run TypeScript type checking 30 31# Internationalization 32yarn intl:extract # Extract translation strings 33yarn intl:compile # Compile translations for runtime 34 35# Build 36yarn build-web # Build web version 37yarn prebuild # Generate native projects 38``` 39 40## Project Structure 41 42``` 43src/ 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 63ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens. 64 65### Basic Usage 66 67```tsx 68import {atoms as a, useTheme} from '#/alf' 69 70function 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 87import {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 93const 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 100import {web, native, ios, android, platform} from '#/alf' 101 102const 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 112import {useBreakpoints} from '#/alf' 113 114const {gtPhone, gtMobile, gtTablet} = useBreakpoints() 115if (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 132Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. 133 134```tsx 135import * as Dialog from '#/components/Dialog' 136 137function 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 167Menus render as a dropdown on web and a bottom sheet dialog on native. 168 169```tsx 170import * as Menu from '#/components/Menu' 171 172function 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 203import {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 236import {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 249import * 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 267All user-facing strings must be wrapped for translation using Lingui. 268 269```tsx 270import {msg, Trans, plural} from '@lingui/macro' 271import {useLingui} from '@lingui/react' 272 273function 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 300yarn intl:extract # Extract new strings to locale files 301yarn 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 310import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' 311 312// Query key pattern 313const RQKEY_ROOT = 'profile' 314export const RQKEY = (did: string) => [RQKEY_ROOT, did] 315 316// Query hook 317export 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 332export 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 348STALE.SECONDS.FIFTEEN // 15 seconds 349STALE.MINUTES.ONE // 1 minute 350STALE.MINUTES.FIVE // 5 minutes 351STALE.HOURS.ONE // 1 hour 352STALE.INFINITY // Never stale 353``` 354 355### Preferences (React Context) 356 357```tsx 358// Simple boolean preference pattern 359import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' 360 361function 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 377import {useSession, useAgent} from '#/state/session' 378 379function 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 394Navigation uses React Navigation with type-safe route parameters. 395 396```tsx 397// Screen component 398import {type NativeStackScreenProps} from '@react-navigation/native-stack' 399import {type CommonNavigatorParams} from '#/lib/routes/types' 400 401type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> 402 403export 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 414import {useNavigation} from '@react-navigation/native' 415 416const navigation = useNavigation() 417navigation.navigate('Profile', {name: 'alice.bsky.social'}) 418 419// Or use the navigate helper 420import {navigate} from '#/Navigation' 421navigate('Profile', {name: 'alice.bsky.social'}) 422``` 423 424## Platform-Specific Code 425 426Use file extensions for platform-specific implementations: 427 428``` 429Component.tsx # Shared/default 430Component.web.tsx # Web-only 431Component.native.tsx # iOS + Android 432Component.ios.tsx # iOS-only 433Component.android.tsx # Android-only 434``` 435 436Example 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 440Platform detection: 441```tsx 442import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env' 443 444if (IS_NATIVE) { 445 // Native-specific logic 446} 447``` 448 449## Import Aliases 450 451Always use the `#/` alias for absolute imports: 452 453```tsx 454// Good 455import {useSession} from '#/state/session' 456import {atoms as a, useTheme} from '#/alf' 457import {Button} from '#/components/Button' 458 459// Avoid 460import {useSession} from '../../../state/session' 461``` 462 463## Footguns 464 465Common 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 473const onConfirm = () => { 474 control.close() 475 navigation.navigate('Home') // May race with dialog animation 476} 477 478// WRONG - same problem 479const onConfirm = () => { 480 control.close() 481 otherDialogControl.open() // Will likely fail or cause visual glitches 482} 483 484// CORRECT - action runs after dialog fully closes 485const onConfirm = () => { 486 control.close(() => { 487 navigation.navigate('Home') 488 }) 489} 490 491// CORRECT - opening another dialog after close 492const onConfirm = () => { 493 control.close(() => { 494 otherDialogControl.open() 495 }) 496} 497 498// CORRECT - state updates after close 499const onConfirm = () => { 500 control.close(() => { 501 setSomeState(newValue) 502 onCallback?.() 503 }) 504} 505``` 506 507This 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 513The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`. 514 515### Controlled vs Uncontrolled Inputs 516 517Prefer `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 535Some 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 541Always test on multiple platforms when using these components. 542 543### React Compiler is Enabled 544 545This 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 549const handlePress = useCallback(() => { 550 doSomething() 551}, [doSomething]) 552 553// JUST WRITE THIS 554const handlePress = () => { 555 doSomething() 556} 557``` 558 559Only 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 5651. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful 566 5672. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>` 568 5693. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles 570 5714. **State**: Use TanStack Query for server state, React Context for UI preferences 572 5735. **Components**: Check if a component exists in `#/components/` before creating new ones 574 5756. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens 576 5777. **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` |