translations
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
# AGENTS.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 19.1
|
||||
- React Native 0.81 with Expo 54
|
||||
- TypeScript 7
|
||||
- React Navigation 7 for routing
|
||||
- TanStack Query (React Query) for data fetching
|
||||
- Lingui 5 for internationalization
|
||||
- Custom design system called ALF (Application Layout Framework)
|
||||
|
||||
Prefer using the latest features available for each of these libraries (exact versions are found in `package.json`). For example, prefer `@lingui/react/macro` over `@lingui/react`. Suggest refactoring legacy or deprecated uses.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm start # Start Expo dev server
|
||||
pnpm web # Start web version
|
||||
pnpm android # Run on Android
|
||||
pnpm ios # Run on iOS
|
||||
|
||||
# Testing & Quality
|
||||
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
|
||||
pnpm test # Run Jest tests
|
||||
pnpm lint # Run Oxlint
|
||||
pnpm typecheck # Run TypeScript type checking
|
||||
pnpm prettier # Run Prettier for code formatting
|
||||
|
||||
# Internationalization
|
||||
# DO NOT run these commands - extraction and compilation are handled by CI
|
||||
pnpm intl:extract # Extract translation strings (nightly CI job)
|
||||
pnpm intl:compile # Compile translations for runtime (nightly CI job)
|
||||
|
||||
# Build
|
||||
pnpm build-web # Build web version
|
||||
pnpm 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)
|
||||
├── features/ # Macro-features that bridge components/screens
|
||||
├── 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
|
||||
```
|
||||
|
||||
### Project Structure in Depth
|
||||
|
||||
When building new things, follow these guidelines for where to put code.
|
||||
|
||||
#### Components vs Screens vs Features
|
||||
|
||||
**Components** are reusable UI elements that are not full screens. Should be
|
||||
platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put
|
||||
these in `/components` if they are shared across screens.
|
||||
|
||||
**Screens** are full-page components that represent a route in the app. They
|
||||
often contain multiple components and handle layout for a page. New screens
|
||||
should go in `/screens` (not `/view/screens`) to encourage better organization
|
||||
and separation from legacy code.
|
||||
|
||||
For complex screens that have specific components or data needs that _are not
|
||||
shared by other screens_, we encourage subdirectories within `/screens/<name>`
|
||||
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
|
||||
`/screens/ProfileScreen/components/`.
|
||||
|
||||
**Features** are higher-level modules that may include context, data fetching,
|
||||
components, and utilities related to a specific feature e.g.
|
||||
`/features/liveNow`. They don't neatly fit into components or screens and often
|
||||
span multiple screens. This is an optional pattern for organizing complex
|
||||
features.
|
||||
|
||||
#### Legacy Directories
|
||||
|
||||
For the most part, avoid writing new files into the `/view` directory and
|
||||
subdirectories. This is the older pattern for organizing screens and components,
|
||||
and it has become a bit disorganized over time. New development should go into
|
||||
`/screens`, `/components`, and `/features`.
|
||||
|
||||
#### State
|
||||
|
||||
The `/state` directory is where we've historically put all our data fetching and
|
||||
state management logic. This is perfectly fine, but for new features, consider
|
||||
organizing state logic closer to the components that use it, either within a
|
||||
feature directory or co-located with a screen. The key is to keep related code
|
||||
together and avoid having "god files" with too much unrelated logic.
|
||||
|
||||
#### Lib
|
||||
|
||||
The `/lib` directory is for utilities and helpers that don't fit into other
|
||||
categories. This can include things like API clients, formatting functions,
|
||||
constants, and other shared logic.
|
||||
|
||||
#### Top Level Directories
|
||||
|
||||
Avoid writing new top-level subdirectories within `/src`. We've done this for a
|
||||
few things in the past that, but we have stronger patterns now. Examples:
|
||||
`/logger` should probably have been written into `/lib`. And `ageAssurance` is
|
||||
better classified within `/features`. We will probably migrate these things
|
||||
eventually.
|
||||
|
||||
### File and Directory Naming Conventions
|
||||
|
||||
Typically JS style for variables, functions, etc. We use ProudCamelCase for
|
||||
components, and camelCase directories and files.
|
||||
|
||||
For "macro" cases in `/features`, `/screens`, or `/components`, co-locate related
|
||||
code in a directory with an `index.tsx` main component plus sibling
|
||||
components/hooks/utils (e.g. `screens/ProfileScreen/index.tsx` +
|
||||
`screens/ProfileScreen/components/`). Keep related code together so it lives where
|
||||
someone would look for it. Don't overdo it: a component that fits in one file
|
||||
should just be `Component.tsx`, not `Component/index.tsx`.
|
||||
|
||||
Platform-specific files are covered under "Platform-Specific Code" below.
|
||||
|
||||
### Comments
|
||||
|
||||
Comment code when necessary to explain the “why” behind something; avoid
|
||||
comments that simply describe the code. Avoid Unicode characters in comments,
|
||||
e.g., use `-` not `—`.
|
||||
|
||||
Always use docblock (`/** */`) syntax for comments that document a type, type
|
||||
member, method, function, or variable. These are the comments a reader expects
|
||||
to find attached to a named declaration, and the docblock form makes that intent
|
||||
clear and surfaces nicely in editor tooltips.
|
||||
|
||||
```tsx
|
||||
type DateFieldProps = {
|
||||
/**
|
||||
* An empty string renders the placeholder and opens the picker at today (or
|
||||
* maximumDate, if earlier).
|
||||
*/
|
||||
value: string | Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-only input. Accepts a string in the format YYYY-MM-DD, or a Date object.
|
||||
*/
|
||||
export function DateField() {}
|
||||
```
|
||||
|
||||
More generally, any multiline comment should use the `/* */` block syntax rather
|
||||
than stacked `//` lines. Reserve `//` for short, single-line comments.
|
||||
|
||||
```tsx
|
||||
/*
|
||||
* The picker requires a valid date, so when value is empty we fall back to
|
||||
* maximumDate (if set) or today.
|
||||
*/
|
||||
const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
|
||||
```
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
|
||||
For larger features or components, co-locate documentation and tests with the
|
||||
code. A `README.md` in the directory (the `/Component/index.tsx` pattern lends
|
||||
itself well to this) can document the whole feature, and feature-specific tests
|
||||
belong alongside it as `Component.test.tsx` or in a `__tests__/` subdirectory.
|
||||
Both are optional.
|
||||
|
||||
## Styling System (ALF)
|
||||
|
||||
ALF is the custom design system. Tailwind-inspired naming with underscores
|
||||
instead of hyphens. Static atoms (`atoms as a`) are theme-independent; theme
|
||||
atoms/palette come from `useTheme()` (`t.atoms.bg`, `t.palette.primary_500`).
|
||||
Style props take an array of atoms + theme atoms + raw styles.
|
||||
|
||||
Order atoms by: flexbox (`a.flex_row`), spacing (`a.px_md`), text (`a.font_bold`),
|
||||
themes (`t.atoms.text`), then raw styles (`{backgroundColor: t.palette.primary_500}`).
|
||||
|
||||
```tsx
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
const t = useTheme()
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]} />
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
Static atoms live in `a.*` (e.g. `a.flex_row`, `a.p_md`, `a.rounded_md`,
|
||||
`a.text_lg`). Theme atoms/palette come from `useTheme()` (`t.atoms.bg`,
|
||||
`t.atoms.text`, `t.atoms.border_contrast_low`, `t.palette.primary_500`).
|
||||
|
||||
**Platform utilities** (`import {web, native, ios, android, platform} from '#/alf'`)
|
||||
return conditional styles inline in a style array: `web({cursor: 'pointer'})`,
|
||||
`native({paddingBottom: 20})`, `platform({ios: {...}, android: {...}, web: {...}})`.
|
||||
|
||||
**Breakpoints:** `const {gtPhone, gtMobile, gtTablet} = useBreakpoints()` from `#/alf`.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Spacing: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (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
|
||||
|
||||
- Prefer fragment shorthand over `Fragment` unless a `key` is needed.
|
||||
- Prefer functions over arrow functions for component declarations.
|
||||
- Prefer prop destructuring via parameters over a const within the component.
|
||||
- Prefer inline types over `Props` types or interfaces.
|
||||
- Set reasonable defaults for optional props.
|
||||
- Prefer the implicit global `React` for types over `type` imports.
|
||||
|
||||
```tsx
|
||||
import {Fragment} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function MyComponent({
|
||||
items = [],
|
||||
children,
|
||||
}: {
|
||||
items?: string[]
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View>
|
||||
<Text>
|
||||
<Trans>Example</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item}>
|
||||
<Text>{index}</Text>
|
||||
<Text>{item}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{children}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Dialog Component
|
||||
|
||||
Lives in `#/components/Dialog`. Bottom sheet on native, modal on web. Manage
|
||||
state with `useDialogControl()`. `Dialog.Handle` renders native-only, `Dialog.Close`
|
||||
web-only. CRITICAL: run any post-close action inside the `control.close(() => ...)`
|
||||
callback (see Footguns). Compound-component usage; canonical example in any dialog
|
||||
under `#/components`.
|
||||
|
||||
### Menu Component
|
||||
|
||||
Lives in `#/components/Menu`. Dropdown on web, bottom sheet dialog on native.
|
||||
`Menu.Divider` is web-only, `Menu.ContainerItem` native-only. Compound API
|
||||
(`Menu.Root` / `Menu.Trigger` / `Menu.Outer` / `Menu.Group` / `Menu.Item`); grep
|
||||
existing usages across the app for a canonical example.
|
||||
|
||||
### Button Component
|
||||
|
||||
`import {Button, ButtonText, ButtonIcon} from '#/components/Button'`. Props:
|
||||
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, prefer `color`)
|
||||
|
||||
### TextField
|
||||
|
||||
Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
|
||||
`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over
|
||||
`value` (see Footguns).
|
||||
|
||||
### Typography
|
||||
|
||||
`import {Text, H1, H2, P} from '#/components/Typography'`. The `Text` default style
|
||||
is `[a.text_sm, a.leading_snug, t.atoms.text]`. Pass the `emoji` prop to any `Text`
|
||||
that may contain emoji - user-generated text (display names etc.) almost always
|
||||
does, so only omit it for static, emoji-free strings: `<Text emoji>Hello!</Text>`.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
All user-facing strings must be wrapped for translation using Lingui. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb.
|
||||
|
||||
Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``.
|
||||
|
||||
Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one – two` over `one—two`.
|
||||
|
||||
```tsx
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
function MyComponent() {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
// Simple strings - use the l macro
|
||||
const title = l`Settings`
|
||||
const errorMessage = l({
|
||||
message: 'Something went wrong',
|
||||
comment: 'Generic error message for unknown/unhandled errors.',
|
||||
context: 'Toast',
|
||||
})
|
||||
|
||||
// Strings with variables
|
||||
const greeting = l`Hello, ${name}!`
|
||||
|
||||
// Pluralization
|
||||
const countLabel = plural(count, {
|
||||
one: '# item',
|
||||
other: '# items',
|
||||
})
|
||||
|
||||
// JSX content - use Trans component
|
||||
return (
|
||||
<Text>
|
||||
<Trans>
|
||||
Welcome to <Text style={a.font_bold}>Bluesky</Text>, {name}!
|
||||
</Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`.
|
||||
|
||||
```tsx
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
function MyComponent() {
|
||||
const {i18n} = useLingui()
|
||||
|
||||
const createdAt = new Date()
|
||||
|
||||
return i18n.date(createdAt, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
|
||||
pnpm intl:extract # Extract new strings to locale files
|
||||
pnpm intl:compile # Compile translations for runtime
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### TanStack Query (Data Fetching)
|
||||
|
||||
Follow the established pattern in `src/state/queries/`; `src/state/queries/feed.ts`
|
||||
is a good canonical reference (it uses `createQueryKey`, matching key roots,
|
||||
`useInfiniteQuery`, and `persistedVersion`).
|
||||
|
||||
- Build query keys with `createQueryKey(root, args)` (from `#/state/queries/util`)
|
||||
using an object for `args`. The key root variable should match the hook name.
|
||||
- Naming conventions: `use[Name]Query` for queries, `use[Name]Mutation` for
|
||||
mutations, `use[Name]CacheMutation` for helpers that mutate cached data directly.
|
||||
- Stale times come from `STALE` in `src/state/queries/index.ts`: `STALE.SECONDS.FIFTEEN`,
|
||||
`STALE.MINUTES.ONE`, `STALE.MINUTES.FIVE`, `STALE.HOURS.ONE`, `STALE.INFINITY`.
|
||||
- Paginated atproto APIs (those returning a `cursor`) use `useInfiniteQuery` with
|
||||
`getNextPageParam: page => page.cursor`; flatten results with
|
||||
`data?.pages.flatMap(page => page.items) ?? []`.
|
||||
- Persist a query across restarts by passing options:
|
||||
`createQueryKey(root, args, {persistedVersion: n})`. Bumping `n` clears the old
|
||||
persisted data and refetches - do this whenever the data shape changes.
|
||||
- Error handling in mutations: don't log network errors (just inform the user),
|
||||
handle typed XRPC errors specifically (e.g. `err instanceof SomeNsid.SomeError`),
|
||||
and send unexpected errors to `logger.error('...', {safeMessage: error})`.
|
||||
|
||||
### Preferences (React Context)
|
||||
|
||||
Boolean/simple UI preferences are exposed as paired hooks from `#/state/preferences`,
|
||||
e.g. `useAutoplayDisabled()` / `useSetAutoplayDisabled()`.
|
||||
|
||||
### Session State
|
||||
|
||||
`import {useSession, useAgent} from '#/state/session'`. `useSession()` gives
|
||||
`hasSession` and `currentAccount`; `useAgent()` gives the atproto agent for API calls.
|
||||
|
||||
## Navigation
|
||||
|
||||
React Navigation with type-safe route params. Type a screen with
|
||||
`NativeStackScreenProps<CommonNavigatorParams, 'X'>` (`route`/`navigation` come
|
||||
from props; params via `route.params`). Navigate programmatically with
|
||||
`useNavigation()`, or the `navigate` helper from `#/Navigation`. Config lives in
|
||||
`src/Navigation.tsx`, routes in `src/routes.ts`, types in `src/lib/routes/types.ts`.
|
||||
|
||||
## Platform-Specific Code
|
||||
|
||||
Use file extensions for platform-specific implementations. The bundler resolves
|
||||
them automatically - just import the base path normally, never a conditional
|
||||
`require()`.
|
||||
|
||||
```
|
||||
Component.tsx # Shared/default
|
||||
Component.web.tsx # Web-only
|
||||
Component.native.tsx # iOS + Android
|
||||
Component.ios.tsx # iOS-only
|
||||
Component.android.tsx # Android-only
|
||||
```
|
||||
|
||||
Prefer grouping variants into a `Component/` directory (`index.tsx`,
|
||||
`index.web.tsx`, `index.native.tsx`) rather than sibling `Component.web.tsx` files,
|
||||
so the shared surface reads as one "macro" module (e.g. `src/components/Dialog/index.tsx`
|
||||
native vs `index.web.tsx` web). The app has both patterns; the directory form is
|
||||
preferred for new code.
|
||||
|
||||
```tsx
|
||||
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
|
||||
import * as storage from '#/state/drafts/storage'
|
||||
|
||||
// WRONG - don't use require() or conditional imports for platform files
|
||||
const storage = IS_NATIVE
|
||||
? require('#/state/drafts/storage')
|
||||
: require('#/state/drafts/storage.web')
|
||||
```
|
||||
|
||||
Runtime platform detection (not for imports): `import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'`.
|
||||
|
||||
## 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
|
||||
<TextField.Input
|
||||
defaultValue={initialEmail}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
|
||||
// Avoid when possible - controlled (can cause performance issues)
|
||||
<TextField.Input
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
```
|
||||
|
||||
### 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 ` `l` `` or `<Trans>`
|
||||
|
||||
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` |
|
||||
@@ -0,0 +1,77 @@
|
||||
# Web list virtualization investigation (APP-2859)
|
||||
|
||||
## Current behavior
|
||||
|
||||
The shared `List` has different implementations by platform. Native renders an
|
||||
animated React Native `FlatList`, which maintains a bounded render window. Web
|
||||
maps the complete `data` array to `Row` components. Every page retained by a
|
||||
query therefore adds React instances, DOM nodes, intersection observers for
|
||||
seen-item tracking, media, and event handlers for the rest of that screen's
|
||||
lifetime.
|
||||
|
||||
The cost grows with loaded history rather than viewport size. Long-running web
|
||||
sessions can consequently accumulate slower reconciliation, layout/style work,
|
||||
larger garbage collections, and more memory pressure. Navigating away releases
|
||||
a list, but a user who keeps scrolling one feed or another persistent timeline
|
||||
continues to grow it.
|
||||
|
||||
## Diagnostic telemetry
|
||||
|
||||
Ten percent of web page sessions report two content-free metrics from the
|
||||
shared web `List`:
|
||||
|
||||
- `web:list:size` fires once per list instance at 100, 250, 500, and 1,000
|
||||
loaded items. It includes mounted row count, content height, page-session age,
|
||||
and Chromium heap figures when the browser exposes them.
|
||||
- `web:list:longTasks` aggregates main-thread tasks over 50 ms into one event
|
||||
per minute while a visible list has at least 100 items. It includes count,
|
||||
total/max blocked time, current list size, and the same memory diagnostics.
|
||||
|
||||
Existing navigation metadata identifies the route/surface. No post, account,
|
||||
or row content is collected. Compare long-task rate and heap use across size
|
||||
buckets; also compare route abandonment or reloads if those events are
|
||||
available downstream. Safari and Firefox do not currently expose the Long
|
||||
Tasks API or Chromium's non-standard heap counters, so size telemetry remains
|
||||
the cross-browser baseline.
|
||||
|
||||
## Implementation shape
|
||||
|
||||
A production virtualizer must support variable-height rows, window scrolling
|
||||
and nested fixed-height containers, pagination sentinels, scroll restoration,
|
||||
`scrollToIndex`, item-seen semantics, headers/footers, and prepend/maintained
|
||||
position behavior. Feed rows also contain video and image state, so recycling
|
||||
must correctly stop offscreen playback and avoid stale row state.
|
||||
|
||||
A practical sequence is:
|
||||
|
||||
1. Build a web-only windowing layer behind the existing `List` interface. Use
|
||||
measured row heights plus an overscan region and top/bottom spacers so the
|
||||
public call sites remain platform-agnostic.
|
||||
2. Preserve the current `IntersectionObserver` contracts on mounted rows and
|
||||
edge sentinels. Implement indexed scrolling from cached measurements, with
|
||||
measurement/refinement when the target has not mounted yet.
|
||||
3. Validate the home feed first: pagination in both directions, refresh,
|
||||
restoration, embeds, keyboard focus, screen readers, and resize/reflow.
|
||||
4. Roll out by surface and compare the new bounded mounted-row count and long
|
||||
tasks against the telemetry above. Keep an escape hatch for incompatible
|
||||
lists until all shared-list behaviors are covered.
|
||||
|
||||
Using CSS `content-visibility` may reduce paint/layout work as an interim
|
||||
experiment, but it does not bound React instances, observers, media state, or
|
||||
JavaScript heap and is not a complete fix.
|
||||
|
||||
## Communities recommendation
|
||||
|
||||
Communities will inherit this behavior if their timeline uses the shared web
|
||||
`List`. Virtualization does not need to block an initial release if community
|
||||
queries keep a deliberately bounded number of pages and normal navigation
|
||||
unmounts the timeline. It should be a prerequisite for an unbounded or highly
|
||||
persistent community timeline, and the API should be built on the shared
|
||||
`List` now so the later virtualizer is inherited without a community-specific
|
||||
rewrite.
|
||||
|
||||
Before launch, use the new size buckets to set that temporary page cap and
|
||||
confirm that expected community sessions do not routinely enter the first
|
||||
bucket where long-task time or memory begins rising materially. Remove this
|
||||
diagnostic instrumentation once the rollout decision has enough production
|
||||
data.
|
||||
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2183
-2159
File diff suppressed because it is too large
Load Diff
+2177
-2153
File diff suppressed because it is too large
Load Diff
+2183
-2159
File diff suppressed because it is too large
Load Diff
+2183
-2159
File diff suppressed because it is too large
Load Diff
+2183
-2159
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2189
-2165
File diff suppressed because it is too large
Load Diff
+2179
-2155
File diff suppressed because it is too large
Load Diff
+2198
-2174
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2176
-2152
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
@@ -2104,7 +2104,7 @@ msgstr ""
|
||||
#: src/components/dialogs/StarterPackDialog.tsx:69
|
||||
#: src/components/StarterPack/ProfileStarterPacks.tsx:267
|
||||
#: src/components/StarterPack/ProfileStarterPacks.tsx:277
|
||||
#: src/view/screens/Profile.tsx:349
|
||||
#: src/view/screens/Profile.tsx:351
|
||||
msgid "Before creating a starter pack, you must first verify your email."
|
||||
msgstr ""
|
||||
|
||||
@@ -2642,7 +2642,7 @@ msgstr ""
|
||||
msgid "Change Handle"
|
||||
msgstr ""
|
||||
|
||||
#: src/screens/Login/LoginForm.tsx:645
|
||||
#: src/screens/Login/LoginForm.tsx:650
|
||||
msgid "Change hosting provider"
|
||||
msgstr "Change hosting provider"
|
||||
|
||||
@@ -3641,8 +3641,8 @@ msgstr ""
|
||||
msgid "Create a starter pack"
|
||||
msgstr ""
|
||||
|
||||
#: src/view/screens/Profile.tsx:573
|
||||
#: src/view/screens/Profile.tsx:574
|
||||
#: src/view/screens/Profile.tsx:575
|
||||
#: src/view/screens/Profile.tsx:576
|
||||
msgid "Create a Starter Pack"
|
||||
msgstr ""
|
||||
|
||||
@@ -5266,7 +5266,7 @@ msgstr ""
|
||||
#: src/screens/Search/SearchResults.tsx:113
|
||||
#: src/screens/StarterPack/StarterPackScreen.tsx:193
|
||||
#: src/view/screens/Feeds.tsx:504
|
||||
#: src/view/screens/Profile.tsx:238
|
||||
#: src/view/screens/Profile.tsx:240
|
||||
#: src/view/shell/desktop/LeftNav.tsx:712
|
||||
#: src/view/shell/Drawer.tsx:592
|
||||
msgid "Feeds"
|
||||
@@ -6194,7 +6194,7 @@ msgstr "Hide group chat updates"
|
||||
msgid "Hide lists"
|
||||
msgstr ""
|
||||
|
||||
#: src/screens/Login/LoginForm.tsx:614
|
||||
#: src/screens/Login/LoginForm.tsx:619
|
||||
msgid "Hide password"
|
||||
msgstr "Hide password"
|
||||
|
||||
@@ -6330,16 +6330,16 @@ msgstr ""
|
||||
#: src/components/dialogs/ServerInput.tsx:138
|
||||
#: src/screens/Login/components/HostingProviderDialog.tsx:159
|
||||
#: src/screens/Login/ForgotPasswordForm.tsx:84
|
||||
#: src/screens/Login/LoginForm.tsx:660
|
||||
#: src/screens/Login/LoginForm.tsx:665
|
||||
msgid "Hosting provider"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: toNiceHostingUrl(state.pdsUrl)
|
||||
#: src/screens/Login/LoginForm.tsx:656
|
||||
#: src/screens/Login/LoginForm.tsx:661
|
||||
msgid "Hosting provider: {0}"
|
||||
msgstr "Hosting provider: {0}"
|
||||
|
||||
#: src/screens/Login/LoginForm.tsx:658
|
||||
#: src/screens/Login/LoginForm.tsx:663
|
||||
msgid "Hosting provider: Bluesky"
|
||||
msgstr "Hosting provider: Bluesky"
|
||||
|
||||
@@ -6864,7 +6864,7 @@ msgid "Labeled by the author."
|
||||
msgstr ""
|
||||
|
||||
#: src/view/com/composer/labels/LabelsBtn.tsx:70
|
||||
#: src/view/screens/Profile.tsx:231
|
||||
#: src/view/screens/Profile.tsx:233
|
||||
msgid "Labels"
|
||||
msgstr ""
|
||||
|
||||
@@ -7163,7 +7163,7 @@ msgstr ""
|
||||
#: src/lib/hooks/useNotificationHandler.ts:158
|
||||
#: src/screens/Settings/NotificationSettings/index.tsx:159
|
||||
#: src/screens/Settings/NotificationSettings/index.tsx:314
|
||||
#: src/view/screens/Profile.tsx:237
|
||||
#: src/view/screens/Profile.tsx:239
|
||||
msgid "Likes"
|
||||
msgstr ""
|
||||
|
||||
@@ -7274,8 +7274,8 @@ msgstr ""
|
||||
|
||||
#: src/Navigation.tsx:174
|
||||
#: src/view/screens/Lists.tsx:60
|
||||
#: src/view/screens/Profile.tsx:232
|
||||
#: src/view/screens/Profile.tsx:240
|
||||
#: src/view/screens/Profile.tsx:234
|
||||
#: src/view/screens/Profile.tsx:242
|
||||
#: src/view/shell/desktop/LeftNav.tsx:722
|
||||
#: src/view/shell/Drawer.tsx:607
|
||||
msgid "Lists"
|
||||
@@ -7504,7 +7504,7 @@ msgid "Me"
|
||||
msgstr "Me"
|
||||
|
||||
#: src/screens/Search/components/AdvancedSearchDialog/index.tsx:375
|
||||
#: src/view/screens/Profile.tsx:235
|
||||
#: src/view/screens/Profile.tsx:237
|
||||
msgid "Media"
|
||||
msgstr ""
|
||||
|
||||
@@ -8033,7 +8033,7 @@ msgstr ""
|
||||
#: src/screens/ProfileList/index.tsx:277
|
||||
#: src/view/screens/Feeds.tsx:545
|
||||
#: src/view/screens/Notifications.tsx:165
|
||||
#: src/view/screens/Profile.tsx:604
|
||||
#: src/view/screens/Profile.tsx:606
|
||||
msgid "New post"
|
||||
msgstr ""
|
||||
|
||||
@@ -8181,7 +8181,7 @@ msgstr "No language filter"
|
||||
|
||||
#: src/components/LikedByList.tsx:90
|
||||
#: src/view/com/post-thread/PostLikedBy.tsx:90
|
||||
#: src/view/screens/Profile.tsx:536
|
||||
#: src/view/screens/Profile.tsx:538
|
||||
msgid "No likes yet"
|
||||
msgstr ""
|
||||
|
||||
@@ -8203,7 +8203,7 @@ msgstr ""
|
||||
msgid "No media filter"
|
||||
msgstr "No media filter"
|
||||
|
||||
#: src/view/screens/Profile.tsx:482
|
||||
#: src/view/screens/Profile.tsx:484
|
||||
msgid "No media yet"
|
||||
msgstr ""
|
||||
|
||||
@@ -8258,7 +8258,7 @@ msgid "No posts here"
|
||||
msgstr ""
|
||||
|
||||
#: src/screens/Profile/Sections/Feed.tsx:84
|
||||
#: src/view/screens/Profile.tsx:440
|
||||
#: src/view/screens/Profile.tsx:442
|
||||
msgid "No posts yet"
|
||||
msgstr ""
|
||||
|
||||
@@ -8271,7 +8271,7 @@ msgstr ""
|
||||
msgid "No recent GIFs yet. Pick one to see it here."
|
||||
msgstr "No recent GIFs yet. Pick one to see it here."
|
||||
|
||||
#: src/view/screens/Profile.tsx:467
|
||||
#: src/view/screens/Profile.tsx:469
|
||||
msgid "No replies yet"
|
||||
msgstr ""
|
||||
|
||||
@@ -8321,7 +8321,7 @@ msgstr "No results found for your query with advanced search filters applied."
|
||||
msgid "No results."
|
||||
msgstr ""
|
||||
|
||||
#: src/view/screens/Profile.tsx:568
|
||||
#: src/view/screens/Profile.tsx:570
|
||||
msgid "No Starter Packs yet"
|
||||
msgstr ""
|
||||
|
||||
@@ -8334,7 +8334,7 @@ msgstr ""
|
||||
msgid "No translation received from your device."
|
||||
msgstr "No translation received from your device."
|
||||
|
||||
#: src/view/screens/Profile.tsx:509
|
||||
#: src/view/screens/Profile.tsx:511
|
||||
msgid "No video posts yet"
|
||||
msgstr ""
|
||||
|
||||
@@ -8394,7 +8394,7 @@ msgid "Not followed by anyone you’re following"
|
||||
msgstr "Not followed by anyone you’re following"
|
||||
|
||||
#: src/Navigation.tsx:169
|
||||
#: src/view/screens/Profile.tsx:130
|
||||
#: src/view/screens/Profile.tsx:132
|
||||
msgid "Not Found"
|
||||
msgstr ""
|
||||
|
||||
@@ -8620,7 +8620,7 @@ msgstr "Oops, this profile doesn't exist. Try scanning another one."
|
||||
#: src/components/StarterPack/ProfileStarterPacks.tsx:375
|
||||
#: src/screens/Settings/AppPasswords.tsx:59
|
||||
#: src/screens/Settings/components/ChangeHandleDialog.tsx:108
|
||||
#: src/view/screens/Profile.tsx:130
|
||||
#: src/view/screens/Profile.tsx:132
|
||||
msgid "Oops!"
|
||||
msgstr ""
|
||||
|
||||
@@ -8763,7 +8763,7 @@ msgstr ""
|
||||
msgid "Opens a dialog to add a content warning to your post"
|
||||
msgstr ""
|
||||
|
||||
#: src/screens/Login/LoginForm.tsx:646
|
||||
#: src/screens/Login/LoginForm.tsx:651
|
||||
msgid "Opens a dialog to change the hosting provider you sign in to"
|
||||
msgstr "Opens a dialog to change the hosting provider you sign in to"
|
||||
|
||||
@@ -9378,13 +9378,13 @@ msgctxt "post-number-in-thread"
|
||||
msgid "Post {index} of {count}"
|
||||
msgstr "Post {index} of {count}"
|
||||
|
||||
#: src/view/screens/Profile.tsx:486
|
||||
#: src/view/screens/Profile.tsx:487
|
||||
#: src/view/screens/Profile.tsx:488
|
||||
#: src/view/screens/Profile.tsx:489
|
||||
msgid "Post a photo"
|
||||
msgstr ""
|
||||
|
||||
#: src/view/screens/Profile.tsx:513
|
||||
#: src/view/screens/Profile.tsx:514
|
||||
#: src/view/screens/Profile.tsx:515
|
||||
#: src/view/screens/Profile.tsx:516
|
||||
msgid "Post a video"
|
||||
msgstr ""
|
||||
|
||||
@@ -9483,7 +9483,7 @@ msgstr ""
|
||||
#: src/screens/ProfileList/index.tsx:164
|
||||
#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:216
|
||||
#: src/screens/StarterPack/StarterPackScreen.tsx:194
|
||||
#: src/view/screens/Profile.tsx:233
|
||||
#: src/view/screens/Profile.tsx:235
|
||||
msgid "Posts"
|
||||
msgstr ""
|
||||
|
||||
@@ -10119,7 +10119,7 @@ msgstr "Replied-to message, tap to scroll to it"
|
||||
#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:218
|
||||
#: src/screens/Settings/NotificationSettings/index.tsx:181
|
||||
#: src/screens/Settings/NotificationSettings/index.tsx:330
|
||||
#: src/view/screens/Profile.tsx:234
|
||||
#: src/view/screens/Profile.tsx:236
|
||||
msgid "Replies"
|
||||
msgstr ""
|
||||
|
||||
@@ -10525,7 +10525,7 @@ msgstr ""
|
||||
msgid "Returns to the previous step"
|
||||
msgstr ""
|
||||
|
||||
#: src/screens/Login/LoginForm.tsx:614
|
||||
#: src/screens/Login/LoginForm.tsx:619
|
||||
msgid "Reveal password"
|
||||
msgstr "Reveal password"
|
||||
|
||||
@@ -11850,7 +11850,7 @@ msgid "Starter packs"
|
||||
msgstr "Starter packs"
|
||||
|
||||
#: src/screens/Search/Explore.tsx:657
|
||||
#: src/view/screens/Profile.tsx:239
|
||||
#: src/view/screens/Profile.tsx:241
|
||||
msgid "Starter Packs"
|
||||
msgstr ""
|
||||
|
||||
@@ -11862,7 +11862,7 @@ msgstr ""
|
||||
msgid "Starter packs let you share your favorite feeds and people with your friends."
|
||||
msgstr ""
|
||||
|
||||
#: src/view/screens/Profile.tsx:566
|
||||
#: src/view/screens/Profile.tsx:568
|
||||
msgid "Starter Packs let you share your favorite feeds and people with your friends."
|
||||
msgstr ""
|
||||
|
||||
@@ -12457,7 +12457,7 @@ msgid "There was an issue fetching notifications. Tap here to try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/screens/Search/Explore.tsx:1011
|
||||
#: src/view/com/posts/PostFeed.tsx:828
|
||||
#: src/view/com/posts/PostFeed.tsx:830
|
||||
msgid "There was an issue fetching posts. Tap here to try again."
|
||||
msgstr ""
|
||||
|
||||
@@ -13604,7 +13604,7 @@ msgstr ""
|
||||
msgid "Use your account email address, or another real email address you control, in case KWS or Bluesky needs to contact you."
|
||||
msgstr ""
|
||||
|
||||
#: src/view/screens/Profile.tsx:393
|
||||
#: src/view/screens/Profile.tsx:395
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
@@ -13863,7 +13863,7 @@ msgstr ""
|
||||
msgid "Video: {0}"
|
||||
msgstr ""
|
||||
|
||||
#: src/view/screens/Profile.tsx:236
|
||||
#: src/view/screens/Profile.tsx:238
|
||||
msgid "Videos"
|
||||
msgstr ""
|
||||
|
||||
@@ -14437,8 +14437,8 @@ msgstr ""
|
||||
msgid "Would you like to save this as a draft to edit later?"
|
||||
msgstr ""
|
||||
|
||||
#: src/view/screens/Profile.tsx:444
|
||||
#: src/view/screens/Profile.tsx:445
|
||||
#: src/view/screens/Profile.tsx:446
|
||||
#: src/view/screens/Profile.tsx:447
|
||||
msgid "Write a post"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+2302
-2278
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2189
-2165
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2176
-2152
File diff suppressed because it is too large
Load Diff
+2181
-2157
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2183
-2159
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2195
-2171
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2185
-2161
File diff suppressed because it is too large
Load Diff
+2181
-2157
File diff suppressed because it is too large
Load Diff
+2183
-2159
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2183
-2159
File diff suppressed because it is too large
Load Diff
+2183
-2159
File diff suppressed because it is too large
Load Diff
+2183
-2159
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2242
-2218
File diff suppressed because it is too large
Load Diff
+2189
-2165
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2184
-2160
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2201
-2177
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2178
-2154
File diff suppressed because it is too large
Load Diff
+2189
-2165
File diff suppressed because it is too large
Load Diff
+2247
-2223
File diff suppressed because it is too large
Load Diff
+2245
-2221
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user