Bluesky app fork with some witchin' additions 💫
witchsky.app
bluesky
fork
client
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├── features/ # Macro-features that bridge components/screens
50├── view/
51│ ├── screens/ # Full-page screens (legacy location)
52│ ├── com/ # Reusable view components
53│ └── shell/ # App shell (navigation bars, tabs)
54├── state/
55│ ├── queries/ # TanStack Query hooks
56│ ├── preferences/ # User preferences (React Context)
57│ ├── session/ # Authentication state
58│ └── persisted/ # Persistent storage layer
59├── lib/ # Utilities, constants, helpers
60├── locale/ # i18n configuration and language files
61└── Navigation.tsx # Main navigation configuration
62```
63
64### Project Structure in Depth
65
66When building new things, follow these guidelines for where to put code.
67
68#### Components vs Screens vs Features
69
70**Components** are reusable UI elements that are not full screens. Should be
71platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put
72these in `/components` if they are shared across screens.
73
74**Screens** are full-page components that represent a route in the app. They
75often contain multiple components and handle layout for a page. New screens
76should go in `/screens` (not `/view/screens`) to encourage better organization
77and separation from legacy code.
78
79For complex screens that have specific components or data needs that _are not
80shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
81e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
82`/screens/ProfileScreen/components/`.
83
84**Features** are higher-level modules that may include context, data fetching,
85components, and utilities related to a specific feature e.g.
86`/features/liveNow`. They don't neatly fit into components or screens and often
87span multiple screens. This is an optional pattern for organizing complex
88features.
89
90#### Legacy Directories
91
92For the most part, avoid writing new files into the `/view` directory and
93subdirectories. This is the older pattern for organizing screens and components,
94and it has become a bit disorganized over time. New development should go into
95`/screens`, `/components`, and `/features`.
96
97#### State
98
99The `/state` directory is where we've historically put all our data fetching and
100state management logic. This is perfectly fine, but for new features, consider
101organizing state logic closer to the components that use it, either within a
102feature directory or co-located with a screen. The key is to keep related code
103together and avoid having "god files" with too much unrelated logic.
104
105#### Lib
106
107The `/lib` directory is for utilities and helpers that don't fit into other
108categories. This can include things like API clients, formatting functions,
109constants, and other shared logic.
110
111#### Top Level Directories
112
113Avoid writing new top-level subdirectories within `/src`. We've done this for a
114few things in the past that, but we have stronger patterns now. Examples:
115`/logger` should probably have been written into `/lib`. And `ageAssurance` is
116better classified within `/features`. We will probably migrate these things
117eventually.
118
119### File and Directory Naming Conventions
120
121Typically JS style for variables, functions, etc. We use ProudCamelCase for
122components, and camelCase directories and files.
123
124When organizing new code, consider if it fits into a single file, or if it
125should be broken down into multiple files. For "macro" component cases, or
126things that live in `/features` or `/screens`, we often follow a pattern of
127having an `index.tsx` for the main component, and then co-locating related
128components, hooks, and utilities in the same directory. For example:
129
130```
131src
132├── screens/
133│ ├── ProfileScreen/
134│ │ ├── index.tsx # Main screen component
135│ │ ├── components/ # Sub-components used only by this screen
136```
137
138Similar patterns can be found in `/features` and `/components`. The idea here is
139to keep related code together and make it easier to navigate.
140
141You should ask yourself: if someone new was looking for the code related to this
142feature or screen, where would they expect to find it? Organizing code in a way
143that matches developer expectations can make the codebase much more
144approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
145easier to understand than having it scattered across multiple directories.
146
147No need to go overboard with this. If a component or feature fits into a single
148file, there's no reason to have a `/Component/index.tsx` file when it could just
149be `/Component.tsx`. Use your judgment based on the complexity and amount of
150related code.
151
152#### Platform Specific Files
153
154We have conflicting patterns in the app for this. The preferred approach is to
155group platform-specific files into a directory as much as possible. For example,
156rather than having `Component.tsx`, `Component.web.tsx`, and
157`Component.native.tsx` in the same directory, we prefer to have a `Component/`
158directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
159related code together and gives us a better visual cue that there are probably
160other files contained within this "macro" feature, whereas `Component.tsx` on
161its own looks more like a single component file.
162
163### Documentation and Tests Within Features
164
165For larger features or components, it's helpful to include a README.md file
166within the directory that explains the purpose of the feature, how it works, and
167any important implementation details. The `/Component/index.tsx` pattern lends
168itself well to this, since the `index.tsx` can be the main component file, and
169the `README.md` can provide documentation for the whole feature. This is
170optional, but can be a nice way to keep documentation close to the code it
171describes.
172
173Similarly, if there are tests that are specific to a component or feature, it
174can be helpful to include them in the same directory, either as
175`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
176related to the component or feature in one place and makes it easier to find and
177maintain tests.
178
179## Styling System (ALF)
180
181ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
182
183### Basic Usage
184
185```tsx
186import {atoms as a, useTheme} from '#/alf'
187
188function MyComponent() {
189 const t = useTheme()
190
191 return (
192 <View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
193 <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
194 Hello
195 </Text>
196 </View>
197 )
198}
199```
200
201### Key Concepts
202
203**Static Atoms** - Theme-independent styles imported from `atoms`:
204```tsx
205import {atoms as a} from '#/alf'
206// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
207```
208
209**Theme Atoms** - Theme-dependent colors from `useTheme()`:
210```tsx
211const t = useTheme()
212// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
213// t.palette.primary_500, t.palette.negative_400, etc.
214```
215
216**Platform Utilities** - For platform-specific styles:
217```tsx
218import {web, native, ios, android, platform} from '#/alf'
219
220const styles = [
221 a.p_md,
222 web({cursor: 'pointer'}),
223 native({paddingBottom: 20}),
224 platform({ios: {...}, android: {...}, web: {...}}),
225]
226```
227
228**Breakpoints** - Responsive design:
229```tsx
230import {useBreakpoints} from '#/alf'
231
232const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
233if (gtMobile) {
234 // Tablet or desktop layout
235}
236```
237
238### Naming Conventions
239
240- Spacing: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (t-shirt sizes)
241- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl`
242- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl`
243- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between`
244- Borders: `border`, `border_t`, `rounded_md`, `rounded_full`
245
246## Component Patterns
247
248### Dialog Component
249
250Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
251
252```tsx
253import * as Dialog from '#/components/Dialog'
254
255function MyFeature() {
256 const control = Dialog.useDialogControl()
257
258 return (
259 <>
260 <Button label="Open" onPress={control.open}>
261 <ButtonText>Open Dialog</ButtonText>
262 </Button>
263
264 <Dialog.Outer control={control}>
265 {/* Typically the inner part is in its own component */}
266 <Dialog.Handle /> {/* Native-only drag handle */}
267 <Dialog.ScrollableInner label={_(msg`My Dialog`)}>
268 <Dialog.Header>
269 <Dialog.HeaderText>Title</Dialog.HeaderText>
270 </Dialog.Header>
271
272 <Text>Dialog content here</Text>
273
274 <Button label="Done" onPress={() => control.close()}>
275 <ButtonText>Done</ButtonText>
276 </Button>
277 <Dialog.Close /> {/* Web-only X button in top left */}
278 </Dialog.ScrollableInner>
279 </Dialog.Outer>
280 </>
281 )
282}
283```
284
285### Menu Component
286
287Menus render as a dropdown on web and a bottom sheet dialog on native.
288
289```tsx
290import * as Menu from '#/components/Menu'
291
292function MyMenu() {
293 return (
294 <Menu.Root>
295 <Menu.Trigger label="Open menu">
296 {({props}) => (
297 <Button {...props} label="Menu">
298 <ButtonIcon icon={DotsHorizontal} />
299 </Button>
300 )}
301 </Menu.Trigger>
302
303 <Menu.Outer>
304 <Menu.Group>
305 <Menu.Item label="Edit" onPress={handleEdit}>
306 <Menu.ItemIcon icon={Pencil} />
307 <Menu.ItemText>Edit</Menu.ItemText>
308 </Menu.Item>
309 <Menu.Item label="Delete" onPress={handleDelete}>
310 <Menu.ItemIcon icon={Trash} />
311 <Menu.ItemText>Delete</Menu.ItemText>
312 </Menu.Item>
313 </Menu.Group>
314 </Menu.Outer>
315 </Menu.Root>
316 )
317}
318```
319
320### Button Component
321
322```tsx
323import {Button, ButtonText, ButtonIcon} from '#/components/Button'
324
325// Solid primary button (most common)
326<Button label="Save" onPress={handleSave} color="primary" size="large">
327 <ButtonText>Save</ButtonText>
328</Button>
329
330// With icon
331<Button label="Share" onPress={handleShare} color="secondary" size="small">
332 <ButtonIcon icon={Share} />
333 <ButtonText>Share</ButtonText>
334</Button>
335
336// Icon-only button
337<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
338 <ButtonIcon icon={XIcon} />
339</Button>
340
341// Ghost variant (deprecated - use color prop)
342<Button label="Cancel" variant="ghost" color="secondary" size="small">
343 <ButtonText>Cancel</ButtonText>
344</Button>
345```
346
347**Button Props:**
348- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
349- `size`: `'tiny'` | `'small'` | `'large'`
350- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
351- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
352
353### Typography
354
355```tsx
356import {Text, H1, H2, P} from '#/components/Typography'
357
358<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
359<P>Paragraph text with default styling.</P>
360<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
361
362// For text with emoji, add the emoji prop
363<Text emoji>Hello! 👋</Text>
364```
365
366### TextField
367
368```tsx
369import * as TextField from '#/components/forms/TextField'
370
371<TextField.LabelText>Email</TextField.LabelText>
372<TextField.Root>
373 <TextField.Icon icon={AtSign} />
374 <TextField.Input
375 label="Email address"
376 placeholder="you@example.com"
377 defaultValue={email}
378 onChangeText={setEmail}
379 keyboardType="email-address"
380 autoCapitalize="none"
381 />
382</TextField.Root>
383```
384
385## Internationalization (i18n)
386
387All user-facing strings must be wrapped for translation using Lingui.
388
389```tsx
390import {msg, plural} from '@lingui/core/macro'
391import {Trans} from '@lingui/react/macro'
392import {useLingui} from '@lingui/react'
393
394function MyComponent() {
395 const {_} = useLingui()
396
397 // Simple strings - use msg() with _() function
398 const title = _(msg`Settings`)
399 const errorMessage = _(msg`Something went wrong`)
400
401 // Strings with variables
402 const greeting = _(msg`Hello, ${name}!`)
403
404 // Pluralization
405 const countLabel = _(plural(count, {
406 one: '# item',
407 other: '# items',
408 }))
409
410 // JSX content - use Trans component
411 return (
412 <Text>
413 <Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
414 </Text>
415 )
416}
417```
418
419**Commands:**
420```bash
421# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
422yarn intl:extract # Extract new strings to locale files
423yarn intl:compile # Compile translations for runtime
424```
425
426## State Management
427
428### TanStack Query (Data Fetching)
429
430```tsx
431// src/state/queries/profile.ts
432import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
433
434// Query key pattern
435const RQKEY_ROOT = 'profile'
436export const RQKEY = (did: string) => [RQKEY_ROOT, did]
437
438// Query hook
439export function useProfileQuery({did}: {did: string}) {
440 const agent = useAgent()
441
442 return useQuery({
443 queryKey: RQKEY(did),
444 queryFn: async () => {
445 const res = await agent.getProfile({actor: did})
446 return res.data
447 },
448 staleTime: STALE.MINUTES.FIVE,
449 enabled: !!did,
450 })
451}
452
453// Mutation hook
454export function useUpdateProfile() {
455 const queryClient = useQueryClient()
456
457 return useMutation({
458 mutationFn: async (data) => {
459 // Update logic
460 },
461 onSuccess: (_, variables) => {
462 queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
463 },
464 onError: (error) => {
465 if (isNetworkError(error)) {
466 // don't log, but inform user
467 } else if (error instanceof AppBskyExampleProcedure.ExampleError) {
468 // XRPC APIs often have typed errors, allows nicer handling
469 } else {
470 // Log unexpected errors to Sentry
471 logger.error('Error updating profile', {safeMessage: error})
472 }
473 }
474 })
475}
476```
477
478**Stale Time Constants** (from `src/state/queries/index.ts`):
479```tsx
480STALE.SECONDS.FIFTEEN // 15 seconds
481STALE.MINUTES.ONE // 1 minute
482STALE.MINUTES.FIVE // 5 minutes
483STALE.HOURS.ONE // 1 hour
484STALE.INFINITY // Never stale
485```
486
487**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
488
489```tsx
490export function useDraftsQuery() {
491 const agent = useAgent()
492
493 return useInfiniteQuery({
494 queryKey: ['drafts'],
495 queryFn: async ({pageParam}) => {
496 const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
497 return res.data
498 },
499 initialPageParam: undefined as string | undefined,
500 getNextPageParam: page => page.cursor,
501 })
502}
503```
504
505To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
506
507### Preferences (React Context)
508
509```tsx
510// Simple boolean preference pattern
511import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
512
513function SettingsScreen() {
514 const autoplayDisabled = useAutoplayDisabled()
515 const setAutoplayDisabled = useSetAutoplayDisabled()
516
517 return (
518 <Toggle
519 value={autoplayDisabled}
520 onValueChange={setAutoplayDisabled}
521 />
522 )
523}
524```
525
526### Session State
527
528```tsx
529import {useSession, useAgent} from '#/state/session'
530
531function MyComponent() {
532 const {hasSession, currentAccount} = useSession()
533 const agent = useAgent()
534
535 if (!hasSession) {
536 return <LoginPrompt />
537 }
538
539 // Use agent for API calls
540 const response = await agent.getProfile({actor: currentAccount.did})
541}
542```
543
544## Navigation
545
546Navigation uses React Navigation with type-safe route parameters.
547
548```tsx
549// Screen component
550import {type NativeStackScreenProps} from '@react-navigation/native-stack'
551import {type CommonNavigatorParams} from '#/lib/routes/types'
552
553type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
554
555export function ProfileScreen({route, navigation}: Props) {
556 const {name} = route.params // Type-safe params
557
558 return (
559 <Layout.Screen>
560 {/* Screen content */}
561 </Layout.Screen>
562 )
563}
564
565// Programmatic navigation
566import {useNavigation} from '@react-navigation/native'
567
568const navigation = useNavigation()
569navigation.navigate('Profile', {name: 'alice.bsky.social'})
570
571// Or use the navigate helper
572import {navigate} from '#/Navigation'
573navigate('Profile', {name: 'alice.bsky.social'})
574```
575
576## Platform-Specific Code
577
578Use file extensions for platform-specific implementations:
579
580```
581Component.tsx # Shared/default
582Component.web.tsx # Web-only
583Component.native.tsx # iOS + Android
584Component.ios.tsx # iOS-only
585Component.android.tsx # Android-only
586```
587
588Example from Dialog:
589- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
590- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
591
592**Important:** The bundler automatically resolves platform-specific files. Just import normally:
593
594```tsx
595// CORRECT - bundler picks storage.ts or storage.web.ts automatically
596import * as storage from '#/state/drafts/storage'
597
598// WRONG - don't use require() or conditional imports for platform files
599const storage = IS_NATIVE
600 ? require('#/state/drafts/storage')
601 : require('#/state/drafts/storage.web')
602```
603
604Platform detection (for runtime logic, not imports):
605```tsx
606import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
607
608if (IS_NATIVE) {
609 // Native-specific logic
610}
611```
612
613## Import Aliases
614
615Always use the `#/` alias for absolute imports:
616
617```tsx
618// Good
619import {useSession} from '#/state/session'
620import {atoms as a, useTheme} from '#/alf'
621import {Button} from '#/components/Button'
622
623// Avoid
624import {useSession} from '../../../state/session'
625```
626
627## Footguns
628
629Common pitfalls to avoid in this codebase:
630
631### Dialog Close Callback (Critical)
632
633**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.
634
635```tsx
636// WRONG - causes bugs with state updates, navigation, opening other dialogs
637const onConfirm = () => {
638 control.close()
639 navigation.navigate('Home') // May race with dialog animation
640}
641
642// WRONG - same problem
643const onConfirm = () => {
644 control.close()
645 otherDialogControl.open() // Will likely fail or cause visual glitches
646}
647
648// CORRECT - action runs after dialog fully closes
649const onConfirm = () => {
650 control.close(() => {
651 navigation.navigate('Home')
652 })
653}
654
655// CORRECT - opening another dialog after close
656const onConfirm = () => {
657 control.close(() => {
658 otherDialogControl.open()
659 })
660}
661
662// CORRECT - state updates after close
663const onConfirm = () => {
664 control.close(() => {
665 setSomeState(newValue)
666 onCallback?.()
667 })
668}
669```
670
671This applies to:
672- Navigation (`navigation.navigate()`, `navigation.push()`)
673- Opening other dialogs or menus
674- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
675- Callbacks passed from parent components
676
677The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
678
679### Controlled vs Uncontrolled Inputs
680
681Prefer `defaultValue` over `value` for TextInput on the old architecture:
682
683```tsx
684// Preferred - uncontrolled
685<TextField.Input
686 defaultValue={initialEmail}
687 onChangeText={setEmail}
688/>
689
690// Avoid when possible - controlled (can cause performance issues)
691<TextField.Input
692 value={email}
693 onChangeText={setEmail}
694/>
695```
696
697### Platform-Specific Behavior
698
699Some components behave differently across platforms:
700- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
701- `Dialog.Close` - Only renders on web (X button)
702- `Menu.Divider` - Only renders on web
703- `Menu.ContainerItem` - Only works on native
704
705Always test on multiple platforms when using these components.
706
707### React Compiler is Enabled
708
709This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically.
710
711```tsx
712// UNNECESSARY - React Compiler handles this
713const handlePress = useCallback(() => {
714 doSomething()
715}, [doSomething])
716
717// JUST WRITE THIS
718const handlePress = () => {
719 doSomething()
720}
721```
722
723Only use `useMemo`/`useCallback` when you have a specific reason, such as:
724- The value is immediately used in an effect's dependency array
725- You're passing a callback to a non-React library that needs referential stability
726
727## Best Practices
728
7291. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
730
7312. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
732
7333. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
734
7354. **State**: Use TanStack Query for server state, React Context for UI preferences
736
7375. **Components**: Check if a component exists in `#/components/` before creating new ones
738
7396. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens
740
7417. **Testing**: Components should have `testID` props for E2E testing
742
743## Key Files Reference
744
745| Purpose | Location |
746|---------|----------|
747| Theme definitions | `src/alf/themes.ts` |
748| Design tokens | `src/alf/tokens.ts` |
749| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
750| Navigation config | `src/Navigation.tsx` |
751| Route definitions | `src/routes.ts` |
752| Route types | `src/lib/routes/types.ts` |
753| Query hooks | `src/state/queries/*.ts` |
754| Session state | `src/state/session/index.tsx` |
755| i18n setup | `src/locale/i18n.ts` |