diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..e1a7d174ed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,591 @@ +# CLAUDE.md - Bluesky Social App Development Guide + +This document provides guidance for working effectively in the Bluesky Social app codebase. + +## Project Overview + +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. + +**Tech Stack:** +- React Native 0.81 with Expo 54 +- TypeScript +- React Navigation for routing +- TanStack Query (React Query) for data fetching +- Lingui for internationalization +- Custom design system called ALF (Application Layout Framework) + +## Essential Commands + +```bash +# Development +yarn start # Start Expo dev server +yarn web # Start web version +yarn android # Run on Android +yarn ios # Run on iOS + +# Testing & Quality +yarn test # Run Jest tests +yarn lint # Run ESLint +yarn typecheck # Run TypeScript type checking + +# Internationalization +yarn intl:extract # Extract translation strings +yarn intl:compile # Compile translations for runtime + +# Build +yarn build-web # Build web version +yarn prebuild # Generate native projects +``` + +## Project Structure + +``` +src/ +├── alf/ # Design system (ALF) - themes, atoms, tokens +├── components/ # Shared UI components (Button, Dialog, Menu, etc.) +├── screens/ # Full-page screen components (newer pattern) +├── view/ +│ ├── screens/ # Full-page screens (legacy location) +│ ├── com/ # Reusable view components +│ └── shell/ # App shell (navigation bars, tabs) +├── state/ +│ ├── queries/ # TanStack Query hooks +│ ├── preferences/ # User preferences (React Context) +│ ├── session/ # Authentication state +│ └── persisted/ # Persistent storage layer +├── lib/ # Utilities, constants, helpers +├── locale/ # i18n configuration and language files +└── Navigation.tsx # Main navigation configuration +``` + +## Styling System (ALF) + +ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens. + +### Basic Usage + +```tsx +import {atoms as a, useTheme} from '#/alf' + +function MyComponent() { + const t = useTheme() + + return ( + + + Hello + + + ) +} +``` + +### Key Concepts + +**Static Atoms** - Theme-independent styles imported from `atoms`: +```tsx +import {atoms as a} from '#/alf' +// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc. +``` + +**Theme Atoms** - Theme-dependent colors from `useTheme()`: +```tsx +const t = useTheme() +// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc. +// t.palette.primary_500, t.palette.negative_400, etc. +``` + +**Platform Utilities** - For platform-specific styles: +```tsx +import {web, native, ios, android, platform} from '#/alf' + +const styles = [ + a.p_md, + web({cursor: 'pointer'}), + native({paddingBottom: 20}), + platform({ios: {...}, android: {...}, web: {...}}), +] +``` + +**Breakpoints** - Responsive design: +```tsx +import {useBreakpoints} from '#/alf' + +const {gtPhone, gtMobile, gtTablet} = useBreakpoints() +if (gtMobile) { + // Tablet or desktop layout +} +``` + +### Naming Conventions + +- Spacing: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `xxl` (t-shirt sizes) +- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl` +- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl` +- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between` +- Borders: `border`, `border_t`, `rounded_md`, `rounded_full` + +## Component Patterns + +### Dialog Component + +Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. + +```tsx +import * as Dialog from '#/components/Dialog' + +function MyFeature() { + const control = Dialog.useDialogControl() + + return ( + <> + + + + {/* Native drag handle */} + + + Title + + + Dialog content here + + + + + + ) +} +``` + +### Menu Component + +Menus render as a dropdown on web and a bottom sheet dialog on native. + +```tsx +import * as Menu from '#/components/Menu' + +function MyMenu() { + return ( + + + {({props}) => ( + + )} + + + + + + + Edit + + + + Delete + + + + + ) +} +``` + +### Button Component + +```tsx +import {Button, ButtonText, ButtonIcon} from '#/components/Button' + +// Solid primary button (most common) + + +// With icon + + +// Icon-only button + + +// Ghost variant (deprecated - use color prop) + +``` + +**Button Props:** +- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` +- `size`: `'tiny'` | `'small'` | `'large'` +- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` +- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`) + +### Typography + +```tsx +import {Text, H1, H2, P} from '#/components/Typography' + +

Heading

+

Paragraph text with default styling.

+Custom text + +// For text with emoji, add the emoji prop +Hello! 👋 +``` + +### TextField + +```tsx +import * as TextField from '#/components/forms/TextField' + +Email + + + + +``` + +## Internationalization (i18n) + +All user-facing strings must be wrapped for translation using Lingui. + +```tsx +import {msg, Trans, plural} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +function MyComponent() { + const {_} = useLingui() + + // Simple strings - use msg() with _() function + const title = _(msg`Settings`) + const errorMessage = _(msg`Something went wrong`) + + // Strings with variables + const greeting = _(msg`Hello, ${name}!`) + + // Pluralization + const countLabel = _(plural(count, { + one: '# item', + other: '# items', + })) + + // JSX content - use Trans component + return ( + + Welcome to Bluesky + + ) +} +``` + +**Commands:** +```bash +yarn intl:extract # Extract new strings to locale files +yarn intl:compile # Compile for runtime (required after changes) +``` + +## State Management + +### TanStack Query (Data Fetching) + +```tsx +// src/state/queries/profile.ts +import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' + +// Query key pattern +const RQKEY_ROOT = 'profile' +export const RQKEY = (did: string) => [RQKEY_ROOT, did] + +// Query hook +export function useProfileQuery({did}: {did: string}) { + const agent = useAgent() + + return useQuery({ + queryKey: RQKEY(did), + queryFn: async () => { + const res = await agent.getProfile({actor: did}) + return res.data + }, + staleTime: STALE.MINUTES.FIVE, + enabled: !!did, + }) +} + +// Mutation hook +export function useUpdateProfile() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data) => { + // Update logic + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({queryKey: RQKEY(variables.did)}) + }, + }) +} +``` + +**Stale Time Constants** (from `src/state/queries/index.ts`): +```tsx +STALE.SECONDS.FIFTEEN // 15 seconds +STALE.MINUTES.ONE // 1 minute +STALE.MINUTES.FIVE // 5 minutes +STALE.HOURS.ONE // 1 hour +STALE.INFINITY // Never stale +``` + +### Preferences (React Context) + +```tsx +// Simple boolean preference pattern +import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' + +function SettingsScreen() { + const autoplayDisabled = useAutoplayDisabled() + const setAutoplayDisabled = useSetAutoplayDisabled() + + return ( + + ) +} +``` + +### Session State + +```tsx +import {useSession, useAgent} from '#/state/session' + +function MyComponent() { + const {hasSession, currentAccount} = useSession() + const agent = useAgent() + + if (!hasSession) { + return + } + + // Use agent for API calls + const response = await agent.getProfile({actor: currentAccount.did}) +} +``` + +## Navigation + +Navigation uses React Navigation with type-safe route parameters. + +```tsx +// Screen component +import {type NativeStackScreenProps} from '@react-navigation/native-stack' +import {type CommonNavigatorParams} from '#/lib/routes/types' + +type Props = NativeStackScreenProps + +export function ProfileScreen({route, navigation}: Props) { + const {name} = route.params // Type-safe params + + return ( + + {/* Screen content */} + + ) +} + +// Programmatic navigation +import {useNavigation} from '@react-navigation/native' + +const navigation = useNavigation() +navigation.navigate('Profile', {name: 'alice.bsky.social'}) + +// Or use the navigate helper +import {navigate} from '#/Navigation' +navigate('Profile', {name: 'alice.bsky.social'}) +``` + +## Platform-Specific Code + +Use file extensions for platform-specific implementations: + +``` +Component.tsx # Shared/default +Component.web.tsx # Web-only +Component.native.tsx # iOS + Android +Component.ios.tsx # iOS-only +Component.android.tsx # Android-only +``` + +Example from Dialog: +- `src/components/Dialog/index.tsx` - Native (uses BottomSheet) +- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives) + +Platform detection: +```tsx +import {isWeb, isNative, isIOS, isAndroid} from '#/platform/detection' + +if (isNative) { + // Native-specific logic +} +``` + +## Import Aliases + +Always use the `#/` alias for absolute imports: + +```tsx +// Good +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' + +// Avoid +import {useSession} from '../../../state/session' +``` + +## Footguns + +Common pitfalls to avoid in this codebase: + +### Dialog Close Callback (Critical) + +**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. + +```tsx +// WRONG - causes bugs with state updates, navigation, opening other dialogs +const onConfirm = () => { + control.close() + navigation.navigate('Home') // May race with dialog animation +} + +// WRONG - same problem +const onConfirm = () => { + control.close() + otherDialogControl.open() // Will likely fail or cause visual glitches +} + +// CORRECT - action runs after dialog fully closes +const onConfirm = () => { + control.close(() => { + navigation.navigate('Home') + }) +} + +// CORRECT - opening another dialog after close +const onConfirm = () => { + control.close(() => { + otherDialogControl.open() + }) +} + +// CORRECT - state updates after close +const onConfirm = () => { + control.close(() => { + setSomeState(newValue) + onCallback?.() + }) +} +``` + +This applies to: +- Navigation (`navigation.navigate()`, `navigation.push()`) +- Opening other dialogs or menus +- State updates that affect UI (`setState`, `queryClient.invalidateQueries`) +- Callbacks passed from parent components + +The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`. + +### Controlled vs Uncontrolled Inputs + +Prefer `defaultValue` over `value` for TextInput on the old architecture: + +```tsx +// Preferred - uncontrolled + + +// Avoid when possible - controlled (can cause performance issues) + +``` + +### Platform-Specific Behavior + +Some components behave differently across platforms: +- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet) +- `Dialog.Close` - Only renders on web (X button) +- `Menu.Divider` - Only renders on web +- `Menu.ContainerItem` - Only works on native + +Always test on multiple platforms when using these components. + +### React Compiler is Enabled + +This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically. + +```tsx +// UNNECESSARY - React Compiler handles this +const handlePress = useCallback(() => { + doSomething() +}, [doSomething]) + +// JUST WRITE THIS +const handlePress = () => { + doSomething() +} +``` + +Only use `useMemo`/`useCallback` when you have a specific reason, such as: +- The value is immediately used in an effect's dependency array +- You're passing a callback to a non-React library that needs referential stability + +## Best Practices + +1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful + +2. **Translations**: Wrap ALL user-facing strings with `msg()` or `` + +3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles + +4. **State**: Use TanStack Query for server state, React Context for UI preferences + +5. **Components**: Check if a component exists in `#/components/` before creating new ones + +6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens + +7. **Testing**: Components should have `testID` props for E2E testing + +## Key Files Reference + +| Purpose | Location | +|---------|----------| +| Theme definitions | `src/alf/themes.ts` | +| Design tokens | `src/alf/tokens.ts` | +| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) | +| Navigation config | `src/Navigation.tsx` | +| Route definitions | `src/routes.ts` | +| Route types | `src/lib/routes/types.ts` | +| Query hooks | `src/state/queries/*.ts` | +| Session state | `src/state/session/index.tsx` | +| i18n setup | `src/locale/i18n.ts` |