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
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` |