forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
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` |