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