diff --git a/.claude/skills/add-feature/SKILL.md b/.claude/skills/add-feature/SKILL.md index 6377560858..02968661b0 100644 --- a/.claude/skills/add-feature/SKILL.md +++ b/.claude/skills/add-feature/SKILL.md @@ -1,196 +1,177 @@ --- name: add-feature -description: Add a new feature or screen to the Ecency mobile app following established patterns +description: Use when adding a new screen, route, or user-facing feature to the Ecency mobile React Native app, covering navigator registration, route params, i18n strings and styling. argument-hint: [feature-name] -disable-model-invocation: true --- # Add Feature -Guide for adding a new feature to the Ecency mobile app. +Ordered procedure for adding a screen. CLAUDE.md covers Redux, TanStack Query, `@ecency/sdk`, +sheets and lint rules in more depth. Easiest to miss: the safe-area root (Step 1) and the params +contract (Step 4). Each one breaks something. -## Screen Structure Patterns +New screens are functional. A few older screens under `src/screens/` are still classes, plus +`application/children/errorBoundary.tsx`, which React requires to be a class. An overlay with no +route of its own, shown with `SheetManager.show`, is a bottom sheet instead: +`src/navigation/sheets.tsx`, see CLAUDE.md. -The app uses two patterns for screens: - -### Pattern 1: Class Component (Legacy — existing screens) - -Many existing screens use class components with container/view separation: - -``` +```text src/screens// - screen/Screen.tsx # Class component with business logic - screen/Styles.ts # EStyleSheet styles + index.ts # local barrel, re-exported from src/screens/index.ts + screen/Screen.tsx + screen/Styles.ts # or .styles.ts + children/ hooks/ # optional ``` -Example: `src/screens/transfer/screen/delegateScreen.tsx` +## Step 1: screen rooted in SafeAreaView -### Pattern 2: Functional Component (Preferred for new screens) +Use `SafeAreaView` from `react-native-safe-area-context` as the root; that is where screens +generally import it from. `src/screens/dappBrowser/screen/dappBrowser.tsx` still takes it from +`react-native`, which is the pattern this rule exists to replace. Do not pass `edges`: it +**replaces** the defaults rather than extending them, so `edges={['bottom']}` drops the top inset +and the header runs under the status bar. That shipped on the Email digests screen; the fix was +removing `edges={['bottom']}`. -New screens should use functional components with hooks: - -``` -src/screens// - screen/Screen.tsx # Functional component - children/ # Sub-components - hooks/ # Custom hooks -``` +Skeleton for `screen/Screen.tsx` (a template, not a quote of any one file): -## Step 1: Create the Screen - -Location: `src/screens//screen/Screen.tsx` - -```typescript +```tsx import React from 'react'; -import { View, Text } from 'react-native'; import { useIntl } from 'react-intl'; -import { useQuery } from '@tanstack/react-query'; -import { getSomeQueryOptions } from '@ecency/sdk'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import { BasicHeader } from '../../../components'; import { useAppSelector } from '../../../hooks'; import { selectCurrentAccount } from '../../../redux/selectors'; -import styles from './Styles'; +import styles from './myFeatureStyles'; -const FeatureScreen = ({ route, navigation }) => { +const MyFeatureScreen = () => { const intl = useIntl(); const currentAccount = useAppSelector(selectCurrentAccount); - const { data, isLoading } = useQuery(getSomeQueryOptions(currentAccount?.name)); return ( - - - {/* Screen content */} - + + + {/* content */} + ); }; -export default FeatureScreen; +export default MyFeatureScreen; ``` -## Step 2: Add Route +`useAppSelector(selectCurrentAccount)` reads the account; `useAuth()` from `src/hooks` when you +only need `{ username, code }`. Reusable UI is exported from the `src/components/index.tsx` barrel +(`BasicHeader`, `MainButton`, `TextInput`, `UserAvatar`, `Icon`). -In `src/constants/routeNames.ts`: +The styles file default-exports +`EStyleSheet.create({ container: { flex: 1, backgroundColor: '$primaryBackgroundColor' } })`. +Variables are defined in `src/themes/lightTheme.ts` and `darkTheme.ts`: `$primaryBlack` text, +`$primaryDarkGray` secondary text, `$primaryBlue` accent, `$primaryLightBackground` cards, +`$iconColor`, `$primaryRed` destructive. Hex literals do not follow the theme. -```typescript -export default { - SCREENS: { - // ... existing - FEATURE: 'Feature', - }, - // ... -}; +## Step 2: both barrels + +`src/screens//index.ts` does `import MyFeature from './screen/myFeatureScreen';` then +`export { MyFeature }; export default MyFeature;`. Add the import plus the name to the export block +in `src/screens/index.ts`. `stackNavigator.tsx` imports its screens from that barrel. +`src/screens/waves` shows the cost of skipping this: it never reached the barrel, so +`botomTabNavigator.tsx` reaches it by path. + +## Step 3: route name + +In `src/constants/routeNames.ts`. Entries are template literals over the shared suffix consts. The +object ends `as const`, which is what makes the route names a literal union: + +```ts + MY_FEATURE: `MyFeature${SCREEN_SUFFIX}`, ``` -## Step 3: Add to Stack Navigator +## Step 4: params contract (skip it and typecheck fails) + +`src/navigation/types.ts` derives `RouteName` from ROUTES, then asserts every route has an entry: -In `src/navigation/stackNavigator.tsx`: +```ts +export type _MissingRouteContracts = AssertNever>; +``` -```typescript -import FeatureScreen from '../screens//screen/Screen'; +A ROUTES entry with no `AppParamList` entry breaks that assertion: the route name does not satisfy +the constraint `never`. `yarn typecheck` runs against an empty baseline, so this fails CI. Add: -// Inside the Stack.Navigator: - +```ts + [ROUTES.SCREENS.MY_FEATURE]: { username?: string } | undefined; ``` -## Step 4: Navigation +Append `| undefined` only if the screen renders with no params. Leaving it off makes params required +at the call sites, which is what you want for a screen that cannot render empty (`WEB_BROWSER`, +`VOTERS`, `ASSET_DETAILS`, `CHAT_THREAD`, `PROFILE_EDIT`). -```typescript -import { useNavigation } from '@react-navigation/native'; -import ROUTES from '../../constants/routeNames'; +## Step 5: register in the navigator -const navigation = useNavigation(); -navigation.navigate(ROUTES.SCREENS.FEATURE, { /* params */ }); +`src/navigation/stackNavigator.tsx` defines `MainStackNavigator` and the root `StackNavigator`. +`MainStackNavigator` registers the drawer as its first screen (`ROUTES.DRAWER.MAIN`), so an +ordinary screen added to it is a sibling of the drawer that pushes over it. The root +`StackNavigator` holds `MainStackNavigator` itself plus pre-auth and full-screen routes (Login, +Register, Welcome, PinCode, WebBrowser). Most new screens go in the main one: + +```tsx + ``` -## Step 5: Internationalization +Put it in the `` block to slide +up, add `options={{ presentation: 'modal' }}` for a true modal. The `as any` casts on existing rows +are legacy prop debt; a new screen needs none. -Add strings to `src/config/locales/en-US.json`: +## Step 6: navigating -```json -{ - "feature.title": "Feature Title", - "feature.description": "Some description" -} +`types.ts` declares `ReactNavigation.RootParamList extends AppParamList`, so the untyped hook is +already checked against Step 4. Omitting params for a route that requires them is a compile error, +not a blank screen. + +```tsx +const navigation = useNavigation(); +navigation.navigate(ROUTES.SCREENS.MY_FEATURE, { username }); ``` -Use with `react-intl`: -```typescript -import { useIntl } from 'react-intl'; -const intl = useIntl(); -intl.formatMessage({ id: 'feature.title' }); +Reading the params back is not settled house style. Most screens destructure a `route` prop, usually +typed `any`; `useRoute` is rare. Prefer keying off Step 4 instead: + +```tsx +import { RouteProp, useRoute } from '@react-navigation/native'; +import { AppParamList } from '../../../navigation/types'; + +const route = useRoute>(); ``` -## Step 6: Styling - -Use `react-native-extended-stylesheet` with theme variables: - -```typescript -import EStyleSheet from 'react-native-extended-stylesheet'; - -export default EStyleSheet.create({ - container: { - flex: 1, - backgroundColor: '$primaryBackgroundColor', - }, - title: { - color: '$primaryBlack', - fontSize: 18, - fontWeight: 'bold', - }, - subtitle: { - color: '$primaryDarkGray', - fontSize: 14, - }, -}); +Outside a component (deep links, redux actions) use the equally typed object form, +`RootNavigation.navigate({ name, params })` from `src/navigation/rootNavigation.tsx`. + +## Step 7: strings + +`src/config/locales/en-US.json` is the catalog you edit; Crowdin owns the translations. It is +**nested**: top-level keys are objects, not dotted ids. `src/utils/flattenMessages.ts` joins the +levels with dots at load, so a nested block is read with a dotted id. + +```json +{ "myfeature": { "title": "My Feature", "empty": "Nothing here yet" } } ``` -Key theme variables: -- `$primaryBackgroundColor` — main background -- `$primaryLightBackground` — card/section background -- `$primaryBlack` — primary text -- `$primaryDarkGray` — secondary text -- `$primaryBlue` — accent/link color -- `$iconColor` — icon tint -- `$primaryRed` — error/destructive - -## Step 7: State Management - -| State type | Where | When | -|---|---|---| -| Blockchain/API data | TanStack Query via SDK query options | Always for server data | -| Global app state | Redux (`src/redux/reducers/`) | Auth, settings, UI state | -| Optimistic updates | Redux cache reducer | Vote caching, post metadata | -| Local component state | `useState`/`useReducer` | Form inputs, toggles | - -### Redux Access -```typescript -import { useAppSelector, useAppDispatch } from '../../hooks'; -import { selectCurrentAccount } from '../../redux/selectors'; -import { someAction } from '../../redux/actions/someAction'; - -const currentAccount = useAppSelector(selectCurrentAccount); -const dispatch = useAppDispatch(); -dispatch(someAction(payload)); +```tsx +intl.formatMessage({ id: 'myfeature.title' }); ``` -## Step 8: Reusable Components +## Step 8: data -Common components from `src/components/`: -- `BasicHeader` — screen header with back button -- `MainButton` — primary action button -- `TextInput` — styled text input -- `UserAvatar` — user profile picture -- `Icon` — icon component -- `Modal` — modal dialog -- `PostCard` — post list item -- `ProfileSummary` — user profile header +Server data uses `@ecency/sdk` query options with TanStack Query; mutations use a wrapper in +`src/providers/sdk/mutations/`. See the SDK Migration section of CLAUDE.md. Keep Redux for auth, +settings, UI state and the optimistic cache reducer. Older reducers (`postsReducer`, +`walletReducer`) still hold server data; do not extend them for a new screen. ## Checklist -- [ ] Screen created with proper structure -- [ ] Route added to `routeNames.ts` -- [ ] Screen registered in `stackNavigator.tsx` -- [ ] i18n strings in `en-US.json` -- [ ] Theme variables used (dark mode support) -- [ ] SDK queries used for blockchain data -- [ ] `yarn lint` passes +- [ ] Root is `SafeAreaView` from `react-native-safe-area-context`, no `edges` override +- [ ] `src/screens//index.ts` re-exported from `src/screens/index.ts` +- [ ] Route in `routeNames.ts` **and** a params entry in `AppParamList` (`src/navigation/types.ts`) +- [ ] Registered in `src/navigation/stackNavigator.tsx` +- [ ] Nested strings in `en-US.json` only +- [ ] Theme variables, no hex literals +- [ ] `yarn lint` and `yarn typecheck` clean diff --git a/.claude/skills/add-mutation/SKILL.md b/.claude/skills/add-mutation/SKILL.md index 492534f14e..318c2250de 100644 --- a/.claude/skills/add-mutation/SKILL.md +++ b/.claude/skills/add-mutation/SKILL.md @@ -1,120 +1,161 @@ --- name: add-mutation -description: Add a new blockchain mutation wrapper using @ecency/sdk in the mobile app +description: Use when adding, wrapping, or calling an @ecency/sdk mutation hook in the mobile app (Hive broadcasts like transfer, follow, vote, delegate, community, engine token, or points) argument-hint: [operation-name] -disable-model-invocation: true --- # Add Mutation -Create a mobile mutation wrapper for an `@ecency/sdk` mutation hook. +Wrap an `@ecency/sdk` mutation hook in `src/providers/sdk/mutations/`. CLAUDE.md +("SDK Migration") covers the adapter; this file is the procedure plus the Hive authority +rules a wrapper must respect. -## Architecture +## 1. Create the wrapper -``` -@ecency/sdk (platform-agnostic mutation hook) - | -src/providers/sdk/mutations/useMutation.ts (mobile wrapper — adds auth context) - | -Screen / Component (calls the mutation) -``` - -The SDK handles all broadcast logic (key signing, HiveSigner, HiveAuth fallback, auth upgrade). -The mobile wrapper just provides the current user and auth context via `useMutationAuth()`. - -## Step 1: Create the Mutation Wrapper - -Location: `src/providers/sdk/mutations/useMutation.ts` - -Every wrapper follows this exact pattern: +`src/providers/sdk/mutations/useMutation.ts`. Most wrappers there are exactly +this shape, so copy it verbatim: ```typescript -import { use } from '@ecency/sdk'; +import { useTransfer } from '@ecency/sdk'; import { useMutationAuth } from './common'; -export function useMutation() { +export function useTransferMutation() { const { username, authContext } = useMutationAuth(); - return use(username, authContext); + return useTransfer(username, authContext, 'async'); } ``` -`useMutationAuth()` (from `./common.ts`) provides: -- `username` — from Redux `selectCurrentAccount` -- `authContext` — `{ adapter: mobilePlatformAdapter, enableFallback: true }` - -## Step 2: Export from Index - -Add to `src/providers/sdk/mutations/index.ts`: +- `'async'` is the broadcast mode, passed as the last positional arg after `authContext`. + Its index varies by hook, so read the signature rather than assuming a position. Pass it + unless the hook has no such parameter. `useBroadcastMutation` takes it as + `{ broadcastMode: 'async' }` inside the options object instead. +- `useMutationAuth()` from `./common.ts` (most wrappers import it) returns + `{ username, authContext }`: `currentAccount?.name` off `selectCurrentAccount`, plus + `useAuthContext()` (`src/providers/sdk/useAuthContext.ts`) building + `{ adapter: createMobilePlatformAdapter({...}), enableFallback: true }`. There is no + `mobilePlatformAdapter` object to import, use the factory. +- Most wrappers take no arguments. `useSetCommunityRoleMutation(community)` plus + `useUpdateCommunityMutation(community)` take the community because the SDK bakes it into + the mutation key. `useAccountRelationsUpdateMutation(target, onSuccess, onError)` takes + the target plus both callbacks because the SDK bakes them into the mutation options. +- A few files are not broadcasts, so they skip `useMutationAuth`, despite + CLAUDE.md still saying all mutation wrappers use it. + `useGenerateImageMutation` plus the digest hooks in + `useNewsletterDigestMutations.ts` (`useSubscribeDigestMutation`, + `useLeaveDigestMutation`, `useUnsubscribeAllDigestsMutation`) bind the HiveSigner `code` + from `useAuth()` (`src/hooks/useAuth.ts`): `useGenerateImage(username, code)`. + `useClaimPointsMutation` instead derives the access token itself, decrypting + `currentAccount.local.accessToken` with `getDigitPinCode(pin)` plus `decryptKey`. + +## 2. Export from the barrel + +One line in `src/providers/sdk/mutations/index.ts`, under the matching domain comment: ```typescript -export { useMutation } from './useMutation'; +export { useTransferMutation } from './useTransferMutation'; ``` -## Step 3: Use in a Screen/Component +## 3. Call it (from the barrel) ```typescript -import { useMutation } from '../providers/sdk/mutations'; - -function MyScreen() { - const mutation = useMutation(); - - const handleSubmit = async () => { - try { - await mutation.mutateAsync({ /* operation params */ }); - // Success handling - } catch (error) { - // Error handling (auth upgrade, cancellation, etc.) - } - }; -} +import { useFollowMutation } from '../providers/sdk/mutations'; +const followMutation = useFollowMutation(); +await followMutation.mutateAsync({ following: data.following }); ``` -## How Auth Works Under the Hood - -The `mobilePlatformAdapter` in `src/providers/sdk/mobilePlatformAdapter.ts` handles: - -1. **Key-based users**: Decrypts posting/active key from AsyncStorage using PIN -2. **HiveSigner users**: Opens WebView for hot signing via `hive-uri` encoded operations -3. **HiveAuth users**: Triggers `HiveAuthBroadcastSheet` for keychain app signing -4. **Auth upgrade**: If active key is needed but user logged in with posting key, shows `AuthUpgradeSheet` to collect the key temporarily (60s expiry) - -You do NOT need to handle any of this in the wrapper — the SDK + adapter handles it automatically. +## No SDK hook for the operation? -## Step 4: If the SDK Mutation Doesn't Exist Yet +There is no `packages/sdk` here. `@ecency/sdk` is an npm dependency, so there +is no local build step. Use the generic `useBroadcastMutation`, as +`useIgnoreUserMutation.ts` does. The positional args: -If the operation isn't in `@ecency/sdk` yet, create it there first: +```typescript +return useBroadcastMutation( + ['hive', 'ignore-user'], // 1 mutation key + username, // 2 username + ({ following }: { following: string }) => [ // 3 ops builder + buildIgnoreOp(username!, following), + ], + undefined, // 4 onSuccess or undefined + authContext, // 5 auth context + 'posting', // 6 authority + { broadcastMode: 'async' }, // 7 options +); +``` -Location: `packages/sdk/src/modules//mutations/use-.ts` +Pass authority as a plain lowercase string. The parameter is typed `AuthorityLevel` from +`@ecency/sdk` (`'posting' | 'active' | 'owner' | 'memo'`); mobile wrappers use +`'posting'` or `'active'`. Do not import the same-named type from +`src/screens/dappBrowser/bridges/bridgeTypes.ts`, an unrelated dapp browser union. + +- `'posting'`: vote, comment, reblog, follow, ignore, community roles +- `'active'`: transfer, delegate, power up/down, savings, limit orders, proposal vote, + witness proxy + +The SDK's exported `OPERATION_AUTHORITY_MAP` is the reference list. It maps both +`account_update` plus `account_update2` to `'active'` flatly, which is right for the common +cases but wrong at both ends, so do not copy it for either one. + +Both account update operations have payload-dependent authority (`custom_json` is the third +payload-dependent case, below): + +| Operation | Payload sets | Authority Hive requires | +|---|---|---| +| both | `owner` | `'owner'` | +| `account_update2` | only `posting_json_metadata` (profile edit, pinned post) | `'posting'` | +| both | anything else | `'active'` | + +The owner row covers BOTH versions. + +`src/utils/hiveOperationAuthority.ts` implements the posting row plus the active row for +`account_update2` only. It does NOT implement the owner row for either version: the function is +typed `(operation: Operation) => 'posting' | 'active'`, so its `owner` branch returns +`'active'`, `account_update` has no branch at all, plus `hiveOperationAuthority.test.ts` has +no owner case. Its doc comment calling v1 "correctly resolves to active" is wrong whenever the +payload sets `owner`. That resolver +serves the hive-uri path (`src/providers/hive/hive.ts` and +`src/hooks/useLinkProcessor.tsx`), where the operations arrive from an external link. +Treat an owner change as unsupported and reject it rather than routing +it to `'active'`. + +`custom_json` is the third payload-dependent case. `required_auths` alone means active, +`required_posting_auths` alone means posting. + +A payload populating BOTH is the case this app does not handle. `resolveTxRequiredAuthority` +collapses a whole transaction to a single `'posting' | 'active'`, which +`src/providers/hive/hive.ts` then turns into one decrypted key, posting or active. So treat a +mixed payload as unsupported by this client and say so, rather than calling it malformed or +picking one authority for it. + +Neither implementation detects the case today. `hiveOperationAuthority.ts` never reads +`required_posting_auths` at all. The SDK's `getCustomJsonAuthority` returns `'active'` as soon +as `required_auths` is non-empty, without checking the other list. Both therefore sign with one +key. + +`useBroadcastMutation` takes `authority` as its +sixth positional parameter, fixed when the hook is created, while `operations` is +`(payload: T) => Operation[]` and only runs at mutate time. The mutation reads the closed-over +value, so a wrapper cannot choose an authority from its payload. If both shapes are possible, +write two hooks with fixed authorities and pick at the call site: ```typescript -import { useBroadcastMutation, AuthorityLevel } from "@/modules/core/mutations/use-broadcast-mutation"; -import { AuthContextV2 } from "@/modules/core/types/auth"; - -export function use(username?: string, auth?: AuthContextV2) { - return useBroadcastMutation( - [""], - async (args: { /* params */ }) => { - return [["", { /* fields */ }]]; - }, - username, - auth, - { - authorityLevel: AuthorityLevel.POSTING, // or ACTIVE - } - ); -} +// posting: profile edit, pinned post, anything touching only posting_json_metadata +export const useUpdateProfileMetadataMutation = () => { /* ..., 'posting' */ }; +// active: json_metadata or a key or authority change other than owner (an owner +// change is unsupported, reject it) +export const useUpdateAccountKeysMutation = () => { /* ..., 'active' */ }; ``` -Then rebuild SDK: `cd ../vision-web && pnpm --filter @ecency/sdk build` - -## Authority Levels +`useBroadcastMutation`'s `authority` parameter defaults to `'posting'`, so pass the right +value explicitly. -- **POSTING**: vote, comment, reblog, follow, community roles, account_update2 (profile) -- **ACTIVE**: transfer, delegate, power up/down, savings, limit orders, account_update (key changes) +Prefer an SDK `buildOp` helper (`buildTransferOp`, `buildVoteOp`) over a hand +written op tuple. -## Common Gotchas +## Gotchas -1. **Don't handle auth manually** — the adapter + SDK handle key decryption, HiveSigner, HiveAuth, and auth upgrade automatically -2. **Don't show toasts in the wrapper** — use the SDK's `onSuccess`/`onError` callbacks or handle in the calling component -3. **Re-export from index** — or the mutation won't be importable from `../providers/sdk/mutations` -4. **Check SDK version** — ensure the SDK hook you're wrapping exists in the installed `@ecency/sdk` version +1. Auth is not your job: the adapter routes PIN key decryption, HiveSigner `hive-uri` + WebView signing, HiveAuth signing, plus the active key upgrade sheet. +2. No toasts or navigation in the wrapper. Do that at the call site or in hook callbacks. +3. Check the hook exists in the installed `@ecency/sdk` first, then run `yarn lint` plus + `yarn typecheck`; the baseline is empty, so any error fails CI. diff --git a/.claude/skills/add-query/SKILL.md b/.claude/skills/add-query/SKILL.md index 30c57a015a..2125f45986 100644 --- a/.claude/skills/add-query/SKILL.md +++ b/.claude/skills/add-query/SKILL.md @@ -1,136 +1,159 @@ --- name: add-query -description: Use an @ecency/sdk query in the mobile app or create a new app-specific query +description: Use when reading server data in the mobile app - wiring an @ecency/sdk query option into a screen, adding or editing a hook under src/providers/queries/, paginating a feed or list with an infinite query, or fixing a query key, enabled guard, or cache persistence problem. argument-hint: [query-name] -disable-model-invocation: true --- # Add Query -Wire up SDK query options in the mobile app, or create app-specific queries. +Read `CLAUDE.md` first (State Management, SDK Migration). Writes are a separate skill: `add-mutation`. -## Using SDK Query Options (Preferred) +## Rule: the SDK owns the fetch -SDK queries are platform-agnostic and shared with the web app. Use them directly: +`@ecency/sdk` exports a large family of `get*QueryOptions` helpers. Most files under +`src/providers/queries/` import from `@ecency/sdk`; a bare `queryFn:` is rare in that directory. +Search the SDK for your own domain first. Write a `queryFn` only when that search comes back empty: -```typescript -import { useQuery } from '@tanstack/react-query'; -import { getPostQueryOptions, getAccountFullQueryOptions } from '@ecency/sdk'; - -function MyComponent({ author, permlink }) { - const { data: post, isLoading } = useQuery(getPostQueryOptions(author, permlink)); - const { data: account } = useQuery(getAccountFullQueryOptions(author)); -} +```bash +D=node_modules/@ecency/sdk/dist/browser/index.d.ts +test -f "$D" || echo 'SDK not installed, run yarn' # a missing file greps as empty, a false all-clear +grep -o "get[A-Za-z]*QueryOptions" "$D" | sort -u | grep -i draft # swap in your domain +grep -n "declare function getPostQueryOptions" "$D" ``` -For non-React contexts (Redux thunks, utilities): +Drop the trailing `| grep -i draft` to list them all. The last grep gives the real argument order. +Do not guess it. In the call sites here, `getPostQueryOptions` takes the observer third. + +## 1. Straight from a component ```typescript -import { getQueryClient } from '@ecency/sdk'; -import { getAccountsQueryOptions } from '@ecency/sdk'; +import { useQuery } from '@tanstack/react-query'; +import { getPostQueryOptions, getAccountFullQueryOptions } from '@ecency/sdk'; -const queryClient = getQueryClient(); -const accounts = await queryClient.fetchQuery(getAccountsQueryOptions([username])); +const observer = currentAccount?.name; +const { data: post, isLoading } = useQuery(getPostQueryOptions(author, permlink, observer)); +const { data: account } = useQuery(getAccountFullQueryOptions(author)); ``` -## Creating App-Specific Queries +## 2. App hook that adds mobile-only options -For queries that are mobile-specific or not in the SDK, add them in `src/providers/queries/`. - -### Query File Structure - -Location: `src/providers/queries/Queries.ts` or `src/providers/queries/Queries/` +The dominant shape: spread the SDK options, then override. Verbatim, +`src/providers/queries/leaderboardQueries/leaderboardQueries.ts`: ```typescript -import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; -import { QueryKeys } from './queryKeys'; +import { useQuery } from '@tanstack/react-query'; +import { getDiscoverLeaderboardQueryOptions } from '@ecency/sdk'; -// Simple query -export function useSomeDataQuery(param: string) { +/** hook used to return leaderboard data using SDK */ +export const useGetLeaderboardQuery = (duration: 'day' | 'week' | 'month') => { return useQuery({ - queryKey: [QueryKeys.SOME_DATA, param], - queryFn: async () => { - const response = await fetch(`https://api.ecency.com/some-endpoint/${param}`); - return response.json(); - }, - enabled: !!param, + ...getDiscoverLeaderboardQueryOptions(duration), + // Opted in explicitly: the client default is off, because waking every query in + // the cache on every resume is far more than this needs. The board lives inside a + // tab that stays mounted, so without this it shows whatever it fetched hours ago + // until the user thinks to pull to refresh. + refetchOnWindowFocus: true, }); -} - -// Infinite query (paginated) -export function useSomeListQuery(param: string) { - return useInfiniteQuery({ - queryKey: [QueryKeys.SOME_LIST, param], - queryFn: async ({ pageParam = '' }) => { - return fetchSomeList(param, pageParam); - }, - initialPageParam: '', - getNextPageParam: (lastPage) => { - if (!lastPage || lastPage.length < 20) return undefined; - return lastPage[lastPage.length - 1].id; - }, - enabled: !!param, - }); -} +}; ``` -### Add Query Keys +Usual overrides: `enabled`, `select`, `staleTime`, `gcTime`, `initialData`. Keep the SDK's +`queryKey` plus `queryFn` so the cache entry stays shared with other surfaces. + +## 3. Private-API queries need the auth pair -Location: `src/providers/queries/queryKeys.ts` +Ecency backend queries take `username` plus an access token. Use `useAuth()` rather than +re-deriving it. From `src/providers/queries/newsletterQueries.ts`: ```typescript -export const QueryKeys = { - // ... existing keys - SOME_DATA: 'SOME_DATA', - SOME_LIST: 'SOME_LIST', +import { useAuth } from '../../hooks'; + +export const useDigestSubscriptionsQuery = () => { + const { username, code } = useAuth(); + return useQuery(getDigestSubscriptionsQueryOptions(username, code)); }; ``` -### Export +## 4. Infinite queries -Add to `src/providers/queries/index.ts`: +SDK `get*InfiniteQueryOptions` already carry `initialPageParam` plus `getNextPageParam`. The repo +rarely hand-rolls those two. Flatten in the hook, do not re-key. From +`src/providers/queries/draftQueries.ts` (comments stripped): ```typescript -export { useSomeDataQuery } from './someQueries'; -``` +const { username, code } = useAuth(); +const enabled = !!username && !!code; -## SDK Configuration +const infiniteQuery = useInfiniteQuery({ + ...getDraftsInfiniteQueryOptions(username ?? '', code ?? '', limit), + enabled, +}); -SDK queries are configured in `src/providers/queries/sdk-config.ts`: +const data = useMemo(() => { + if (!infiniteQuery.data?.pages) return []; + return infiniteQuery.data.pages.flatMap((page) => page.data); +}, [infiniteQuery.data?.pages]); -- `ConfigManager.setQueryClient(queryClient)` — shares the QueryClient -- `ConfigManager.setHiveNodes(nodes)` — configures RPC nodes with failover -- `ConfigManager.setPrivateApiHost(host)` — Ecency backend API -- `ConfigManager.setDmcaLists(lists)` — DMCA content filtering +return { ...infiniteQuery, data, pagesLoaded: infiniteQuery.data?.pages?.length ?? 0 }; +``` -This is called once at app startup. You don't need to touch it for new queries. +## 5. Query keys -## Common Patterns +- **SDK keys**: `import { QueryKeys } from '@ecency/sdk'`. Namespaced factories, not strings: + `QueryKeys.posts.draftsInfinite(username, limit)`, `QueryKeys.accounts.full(name)`, + `QueryKeys.polls.details(author, permlink)`. Use these to invalidate or seed an SDK cache entry. +- **Mobile-only keys**: `src/providers/queries/queryKeys.ts` is a *default* export named `QUERIES` + with a nested shape. Files import that default (`import QUERIES from '/queryKeys'`, + so the specifier depends on the file) then `queryKey: [QUERIES.WALLET.GET_ACTIVITIES, username]`. + There is no local `QueryKeys` export. -### Guard Undefined Params -Always use `enabled` to prevent queries from running with missing params: -```typescript -useQuery({ - queryKey: [QueryKeys.POST, author, permlink], - queryFn: () => fetchPost(author!, permlink!), - enabled: !!author && !!permlink, -}); -``` +## 6. Hand-rolled query (last resort) + +Only when the SDK has nothing. Verbatim, `src/providers/queries/settingsQueries.ts`: -### Cache Priming -For optimistic updates, use Redux cache reducer: ```typescript -import { useInjectVotesCache } from '../../hooks'; -// Injects cached vote data into post objects -const posts = useInjectVotesCache(rawPosts); +export const useGetServersQuery = () => { + return useQuery({ + queryKey: [QUERIES.SETTINGS.GET_SERVERS], + queryFn: getNodes, + placeholderData: [...SERVER_LIST], + staleTime: 0, + }); +}; ``` -### Wallet Queries -Wallet-specific queries live in `src/providers/queries/walletQueries/` and use SDK query options for blockchain data (delegations, balances, etc.). - -## Common Gotchas - -1. **Use SDK query options when available** — don't duplicate blockchain queries that exist in `@ecency/sdk` -2. **Don't forget `enabled`** — prevents queries from running before params are ready -3. **Return `undefined` from getNextPageParam** to stop pagination, not `null` -4. **Query cache persists** to AsyncStorage via TanStack Query persistence — be mindful of cache size +`getNodes` comes from `src/providers/ecency/ecency.ts`, a provider module, not a bare fetch. + +## 7. Export + +`src/providers/queries/index.ts` uses `export * from './Queries'`. It also re-exports +`getQueryClient` from the SDK; the rest of the file is local (`initQueryClient` plus the +persistence allowlist). A subdirectory carries its own `index.ts` that re-exports namespaces. + +## Gotchas + +1. **Persistence is an allowlist.** `_shouldDehydrateQuery` in `src/providers/queries/index.ts` + switches on `queryKey[0]`, then narrows on `queryKey[1]`. A namespace with no case falls to the + default and is dropped, so a new namespace is not persisted until you add its case. Within a + case the subtype handling differs per namespace: some subtypes are dropped, some persist only + while a single page is loaded, some persist wholesale. Read the switch before assuming a new key + persists. +2. **Guard with `enabled`** when a param can be undefined. An `undefined` anywhere in a query key + also blocks persistence. +3. Returning `undefined` from `getNextPageParam` stops pagination; that is what the repo's + hand-rolled case returns. +4. **Optimistic vote data is no longer Redux.** Call `updateVoteInQueryCaches()` and read back via + `applyRecentVoteOverrideToEntry()` from `src/providers/queries/postQueries/voteCacheUtils.ts`; + seed a post before navigation with `usePostsCachePrimer()`. `useInjectVotesCache` is gone, + though CLAUDE.md's Post Data Flow still lists it. +5. **Non-React code**: import `getQueryClient` from the app barrel `providers/queries` rather than + the SDK: `await queryClient.fetchQuery(getAccountsQueryOptions([username]))`. +6. `src/providers/queries/sdk-config.ts` runs from `initQueryClient()` and configures + `ConfigManager` (query client, private API host, image host, Hive nodes, DMCA lists). Adding a + query rarely requires touching it. + +Prettier width is 100 (`.prettierrc`). Finish with `yarn lint`, `yarn typecheck` plus +`yarn test:ci`; `.github/workflows/test.yml` runs all three on every PR. The baseline in +`tsc-baseline.json` is empty, so any type error fails. Two co-located tests in +`src/providers/queries/` read `index.ts` and assert the `export * from './Queries';` line +from section 7, so a missing barrel export fails jest. diff --git a/.claude/skills/add-sheet/SKILL.md b/.claude/skills/add-sheet/SKILL.md index 99abf35170..e4e98065dd 100644 --- a/.claude/skills/add-sheet/SKILL.md +++ b/.claude/skills/add-sheet/SKILL.md @@ -1,181 +1,210 @@ --- name: add-sheet -description: Add a new bottom sheet (action sheet) to the mobile app +description: Use when adding, registering, or debugging a bottom sheet (action sheet) in the mobile app, including when a sheet's cancel or dismissal reaches the caller as the wrong result. argument-hint: [sheet-name] -disable-model-invocation: true --- # Add Sheet -Create a new bottom sheet using `react-native-actions-sheet`. +Registry, show call and mount lifecycle: CLAUDE.md "Sheets (Bottom Sheets)". This file adds +the procedure plus the result convention new sheets here should follow. -## Step 1: Create the Sheet Component +## Resolve with an object, gate on a named field -Location: `src/components//.tsx` +`react-native-actions-sheet` 0.9.7, `node_modules/react-native-actions-sheet/dist/src/index.js:408`: + +```js +actionSheetEventManager.publish("onclose_".concat(sheetId), data || payloadRef.current || data, currentContext); +``` + +`data` is what you passed to `SheetManager.hide(id, { payload: data })`. `payloadRef.current` is +NOT the show payload: it tracks ``'s own `payload` prop (`payload = _a.payload` at +index.js:53, `useRef(payload)` at 87, `payloadRef.current = payload` at 139). The +`SheetManager.show` payload goes somewhere else entirely, to the registered component as a prop +from the provider (``, `dist/src/provider.js:160`). + +Sheets in `src/` do not forward that prop down into ``: `grep -rn "payload=" src/` +returns zero hits. So `payloadRef.current` is `undefined` for the sheets here. +`data || payloadRef.current || data` collapses to `data`, so a falsy return does reach the caller +intact today. Same expression on `onBeforeClose` (line 385) and `onClose` (line 401). + +Still resolve with an object and gate on a named field. Two reasons: + +- A backdrop tap, swipe down or hardware back closes with `data === undefined`, so `show()` + resolves `undefined`. Truthiness cannot separate that dismissal from a sheet that deliberately + answered `false`, `0` or `''`. A named field can. +- The substitution is one prop away from going live. Adding `payload={payload}` to an + `` would silently turn every falsy cancel in that sheet into the truthy show + payload, with no type error and no crash. + +Copy `modNotesSheet`, `communityRoleEditSheet`, `walletHistoryFiltersSheet` or +`newsletterDigestSheet`. All four resolve `{ cancelled: true }` on cancel. +`searchFiltersSheet` is apply-only, with no cancel control, so it is not a model here. + +`src/components/authUpgradeSheet/authUpgradeSheet.tsx` is one counter-example: it cancels with +`_close(false)` while `src/providers/sdk/mobilePlatformAdapter.ts:317` gates on +`if (!result) return false;`. That reads correctly right now, since cancel and dismissal are both +falsy there and both mean the same thing, but it is one of the sheets that breaks first if anyone +gives it a `payload` prop. + +Comments across `src/` (in `sheets.tsx`, in several sheet components, in several screens) justify +this convention by claiming a dismissal resolves the payload object. The convention is right; that +reason is not. + +## Step 1: Component + +`src/components//.tsx` is the usual path for a component typed with +`SheetProps<'...'>`. Other sheets use the enum form `SheetProps`, which is equally +accepted. Trimmed from `src/components/modNotesSheet/modNotesSheet.tsx`: ```typescript -import React, { useState } from 'react'; -import { View, Text } from 'react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-sheet'; import EStyleSheet from 'react-native-extended-stylesheet'; import { MainButton } from '../mainButton'; -const MySheet: React.FC> = ({ sheetId, payload }) => { - const intl = useIntl(); - - // IMPORTANT: react-native-actions-sheet keeps registered sheets mounted. - // State persists between invocations. Reset in useEffect if needed: - // useEffect(() => { resetState(); }, [payload]); +// Matches the SheetNames value. Sheets keep this so `hide` has an id even when the +// sheet is rendered outside the registry. +const FALLBACK_SHEET_ID = 'my_sheet'; - const _handleConfirm = () => { - // Return a value to the caller - SheetManager.hide(sheetId, { payload: 'result_value' }); - }; +/** `{ value }` on confirm, `{ cancelled: true }` on cancel. A backdrop, swipe or back + * dismissal resolves `undefined`, so callers gate on a string `value`, never on + * truthiness. */ +export interface MySheetResult { + value?: string; + cancelled?: boolean; +} - const _handleCancel = () => { - // Return false/undefined to indicate cancellation - SheetManager.hide(sheetId, { payload: false }); +const MySheet: React.FC> = ({ sheetId, payload }) => { + const intl = useIntl(); + const [value, setValue] = useState(''); + const closedRef = useRef(false); + + const _reset = useCallback(() => { + closedRef.current = false; + setValue(''); + }, []); + + // onBeforeShow fires the same reset on every fresh presentation, which a registered + // sheet also gets from its fresh mount. This effect covers the one case both miss, a + // payload swap while the sheet is already open, because use-sheet-manager.js drops the + // re-show with `if (visible) return;` before onBeforeShow can run. Do not delete it as + // redundant. + useEffect(() => { + _reset(); + }, [payload, _reset]); + + // closedRef stops a double tap firing two hides, which would resolve twice. + const _close = (result: MySheetResult) => { + if (closedRef.current) return; + closedRef.current = true; + SheetManager.hide(sheetId || FALLBACK_SHEET_ID, { payload: result }); }; return ( - - - - {intl.formatMessage({ id: 'my_sheet.title' })} - - - + + _close({ value: value.trim() })} /> + _close({ cancelled: true })} /> ); }; const styles = EStyleSheet.create({ - container: { - padding: 16, - backgroundColor: '$primaryBackgroundColor', - }, - title: { - fontSize: 18, - fontWeight: 'bold', - color: '$primaryBlack', - }, + sheetContainer: { paddingHorizontal: 0, backgroundColor: '$primaryBackgroundColor' }, }); export default MySheet; ``` -## Step 2: Create Index File - -Location: `src/components//index.ts` - -```typescript -export { default as MySheet } from './'; -``` +Colors come from EStyleSheet theme variables in `src/themes/` (`$primaryBackgroundColor`, +`$primaryBlack`, `$primaryDarkGray`, `$iconColor`) rather than a hex literal. For a color a prop +needs as a plain string rather than a style, resolve it with `EStyleSheet.value('$primaryDarkGray')`, as +`modNotesSheet` does for `placeholderTextColor`. -## Step 3: Export from Components +## Step 2: Folder index -Add to `src/components/index.tsx`: +`src/components//index.ts`. Sheet folders re-export their sheet with the first line; +those that also publish a result type add the second: ```typescript -export { MySheet } from './'; +export { default as MySheet } from './'; +export type { MySheetResult } from './'; ``` -## Step 4: Register the Sheet +A sheet that `sheets.tsx` imports by path can skip this file entirely. `walletHistoryFiltersSheet` +has no `index.ts`. -In `src/navigation/sheets.tsx`: +## Step 3: Components barrel -1. Import the component: -```typescript -import { MySheet } from '../components'; -``` +`src/components/index.tsx` is an import list plus one `export { ... }` block at line 164. Add +`import { MySheet } from './';` plus a `MySheet,` entry inside that block. There is no +`export ... from` line to add. A sheet nothing else imports can skip this step and be imported +in `sheets.tsx` by path, as some registrations are. -2. Add to `SheetNames` enum: -```typescript -export enum SheetNames { - // ... existing - MY_SHEET = 'my_sheet', -} -``` +## Step 4: Register in `src/navigation/sheets.tsx` -3. Register: -```typescript -registerSheet(SheetNames.MY_SHEET, MySheet); -``` +Add the `SheetNames` member (`MY_SHEET = 'my_sheet',`), the +`registerSheet(SheetNames.MY_SHEET, MySheet);` call, then extend `Sheets`. **The key must be a +string literal, not `[SheetNames.MY_SHEET]`**: string enum member types are nominal, so with a +computed key `keyof Sheets` carries the enum member rather than the string literal and +`SheetProps<'my_sheet'>` stops resolving. -4. Add TypeScript definition: ```typescript declare module 'react-native-actions-sheet' { interface Sheets { - // ... existing - [SheetNames.MY_SHEET]: SheetDefinition<{ - payload: { - someParam: string; - }; - returnValue: string | false; // what hide() returns + my_sheet: SheetDefinition<{ + payload: { someParam: string }; + // `{ value }` on confirm, `{ cancelled: true }` on cancel, `undefined` on a + // backdrop, swipe or back dismissal. Gate on `value`, not on truthiness. + returnValue: MySheetResult | undefined; }>; } } ``` -## Step 5: Show the Sheet +Skipping this fails `yarn typecheck`: the `everySheetHasDefinition` assertion at the bottom of +the file names any `SheetNames` member missing from the augmentation. -From anywhere in the app: +## Step 5: Show it and read the result ```typescript -import { SheetManager } from 'react-native-actions-sheet'; -import { SheetNames } from '../navigation/sheets'; - -// Async — waits for sheet to close and returns the result -const result = await SheetManager.show(SheetNames.MY_SHEET, { - payload: { someParam: 'value' }, -}); +const result = await SheetManager.show(SheetNames.MY_SHEET, { payload: { someParam: 'x' } }); -if (result) { - // User confirmed -} else { - // User cancelled (backdrop tap or explicit cancel) -} +// Only a confirmation carries a string `value`. Cancel yields { cancelled: true }; a +// backdrop, swipe or back dismissal yields undefined. Both are rejected here. +const confirmed = typeof result?.value === 'string' ? result.value : ''; +if (!confirmed) return; ``` -## Step 6: Add i18n Strings - -In `src/config/locales/en-US.json`: - -```json -{ - "my_sheet.title": "Sheet Title", - "my_sheet.confirm": "Confirm", - "my_sheet.cancel": "Cancel" -} -``` +Real callers: `src/components/postOptionsModal/container/postOptionsModal.tsx:742` +(`typeof result?.notes === 'string'`) and +`src/screens/assetDetails/screen/assetDetailsScreen.tsx:265` (`!Array.isArray(result?.operations)`). -## Styling Notes +## Step 6: i18n strings -- Use `$primaryBackgroundColor` for backgrounds (supports dark mode) -- Use `$primaryBlack` for text (adapts to theme) -- Use `$iconColor` for icons -- Import `EStyleSheet` from `react-native-extended-stylesheet` +Edit `src/config/locales/en-US.json` only; Crowdin owns the other locales. The file is nested +objects, not dotted keys, while `formatMessage` ids stay dotted: -## State Reset Pattern - -Sheets stay mounted. If your sheet has state, reset it when payload changes: - -```typescript -useEffect(() => { - setInput(''); - setError(''); - setLoading(false); -}, [payload]); +```json + "my_sheet": { "title": "Sheet Title", "confirm": "Confirm", "cancel": "Cancel" }, ``` -## Common Gotchas - -1. **Sheets are always mounted** — state persists between show/hide cycles. Always reset state on payload change. -2. **Use `SheetManager.hide(sheetId, { payload: value })` to return values** — the `show()` promise resolves with this value. -3. **Backdrop tap returns `undefined`** — handle this as cancellation in the caller. -4. **Use `useIntl` for strings** — not hardcoded text. -5. **Theme support** — use EStyleSheet `$variables` for colors, not hardcoded values. +## Lifecycle + +- **Sheets mount on a fresh show and unmount on hide**: the library renders the registered + component only while that sheet is visible. `useState` initials are fresh on that open, so + nothing stale needs clearing on mount; unmount cleanups run on close. The exception is a + `show()` against a sheet that is already open: the stored payload is replaced with no remount, + so reset on the `payload` prop as well, as Step 1 does. The CLAUDE.md bullet states the mount + rule without that exception. +- **Sheets render outside the ErrorBoundary**: `SheetProvider` returns `<>{children}{sheets}`, + so sheets are siblings of `` while the boundary sits inside it + (`src/screens/application/index.tsx:17`). A throw in a sheet render or effect cleanup is fatal. + Be careful with native or Expo shared objects in cleanups. +- **The payload is captured at show time**: it is stored in state at that show, so a callback + passed inside a payload keeps the closure it had then. Route anything that changes through a + ref, as `src/components/quickPostModal/quickPostModalContent.tsx:565-575` does. diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index a8ebcd2891..e55d26c0f9 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -1,85 +1,139 @@ --- name: code-review -description: Review code changes for bugs, pattern violations, and common pitfalls in vision-mobile +description: Review a vision-mobile React Native change (diff, branch, PR or file) against this repo's own traps and conventions: action sheet return values, setNativeProps caret, safe-area edges, editor teardown order, notification routing copies, SDK mutation wrappers, EStyleSheet theming. argument-hint: [file-or-branch] -disable-model-invocation: true --- # Code Review -Review code for bugs, anti-patterns, and issues specific to the vision-mobile codebase. - -## How to Review - -1. **Read the changed files** — understand what changed and why -2. **Verify each finding against current code** — don't assume; read the actual file before flagging -3. **Categorize findings** by severity: - - **Inline (must fix)**: Bugs, security issues, data corruption risks - - **Outside-diff (should fix)**: Issues in unchanged code exposed by the change - - **Nitpick (nice to have)**: Style, naming improvements -4. **Only flag what's confirmed** — if a finding doesn't apply to current code, skip it - -## What to Check - -### React & Hooks -- [ ] **Missing cleanup in useEffect** — addEventListener needs removeEventListener, setInterval needs clearInterval -- [ ] **Missing dependencies** in useMemo/useCallback/useEffect arrays -- [ ] **setState on unmounted components** — async callbacks may fire after unmount -- [ ] **Stale state in callbacks** — event handlers capturing stale closure values - -### Class Components (Legacy) -- [ ] **Missing setState guards** — async callbacks setting state after unmount -- [ ] **Stale `this.state` in async flows** — use callback form: `this.setState((prev) => ...)` -- [ ] **Missing cleanup in componentWillUnmount** — timers, subscriptions - -### React Query / SDK -- [ ] **Undefined parameter guards** — queries must use `enabled: !!param` when params can be undefined -- [ ] **Cache key consistency** — use `QueryKeys` from `src/providers/queries/queryKeys.ts` or SDK -- [ ] **Missing invalidation** — mutations should invalidate affected queries -- [ ] **Infinite query pageParam** — `getNextPageParam` must return `undefined` (not `null`) to stop pagination - -### SDK Mutations -- [ ] **Authority level mismatch** — posting vs active must match the Hive operation -- [ ] **Missing auth context** — mutation wrappers must use `useMutationAuth()` from `./common.ts` -- [ ] **Manual auth handling** — don't decrypt keys or handle HiveSigner/HiveAuth in wrappers; the adapter does this - -### Sheets (Action Sheets) -- [ ] **State not reset on payload change** — sheets stay mounted; state persists between invocations -- [ ] **Missing SheetDefinition** — TypeScript definition needed in `sheets.tsx` for payload/returnValue -- [ ] **Unhandled undefined return** — backdrop tap returns undefined; callers must handle this - -### Styling -- [ ] **Hardcoded colors** — use EStyleSheet `$variables` for theme support -- [ ] **Missing dark mode** — new styles should use theme variables, not literal colors -- [ ] **Platform-specific issues** — test on both iOS and Android; use `Platform.select()` when needed - -### Hive Blockchain Specific -- [ ] **RPC response validation** — don't trust API responses blindly -- [ ] **Vesting share format** — `vestsToHp()` expects string format (e.g., "123.456789 VESTS") -- [ ] **DMCA filtering** — post queries should filter flagged content via SDK - -### i18n -- [ ] **Hardcoded strings** — use `intl.formatMessage({ id: 'key' })` for all user-facing text -- [ ] **Missing locale keys** — new strings must be added to `src/config/locales/en-US.json` -- [ ] **Format inconsistency** — keys use dot notation: `"section.action"` (e.g., `"transfer.confirm"`) - -### Common Bug Patterns Found in This Codebase - -1. **Stale isAmountValid state** — validation flags not reset when inputs clear or errors occur -2. **Silent no-ops in auth fallbacks** — empty case blocks that silently do nothing for certain auth types -3. **Race conditions in async setState** — multiple concurrent state updates in class components -4. **Missing error.message extraction** — error objects displayed as `[object Object]` instead of `.message` -5. **Inconsistent validation** — amount validated against minimum in one path but not maximum (available balance) -6. **Unused props not cleaned up** — TypeScript interface still declares props that are no longer passed - -## Output Format - -For each finding: -``` -**[SEVERITY]** file:line — Description -- What's wrong -- Why it matters -- Suggested fix -``` - -Severities: `BUG`, `SECURITY`, `PERF`, `STYLE`, `NITPICK` +Architecture, commands, TypeScript and ESLint rules live in CLAUDE.md. This file holds +the traps and repo conventions this review gates on. Confirm each finding against the +code on disk before reporting it. + +## Action sheets + +- [ ] **Resolve an object, gate on a named field.** A repo convention, not something the + library enforces today. Two different payloads are involved, so keep them apart when + reviewing: + - the SHOW payload, `SheetManager.show(name, { payload })`, which the provider hands to + the registered component as a prop. Every sheet uses this. + - ``, ActionSheet's OWN prop. Nothing in `src/` sets it, and + forwarding the show payload into it is not the same thing. + + Only the second one feeds `payloadRef`, and the library resolves a close with + `data || payloadRef.current || data`. Since no sheet sets that prop, `payloadRef.current` + is `undefined` and a falsy result reaches the caller intact today. Keep the convention + anyway: adding `payload` to one `` would silently turn every falsy cancel in + that sheet into a confirm. A dismissal also resolves `undefined`, which truthiness cannot + tell apart from a deliberate `false`. Sheets resolve `{ cancelled: true }` + or `{ field: value }` instead. Sheets document their own contract, e.g. + `src/components/searchFiltersSheet/searchFiltersSheet.tsx`. Callers test the field, + abridged from `src/screens/searchResult/screen/searchResultScreen.tsx:65-77`: + ```ts + const result = await SheetManager.show(SheetNames.SEARCH_FILTERS, { + payload: { filters, searchValue: clipSearchValue(searchInputValue) }, + }); + if (result && typeof result === 'object' && result.filters) { ... } + ``` +- [ ] **Sheets unmount on hide**, so mount-time resets are enough and cleanups run on + close (CLAUDE.md, Sheets). Reject "state persists between invocations". +- [ ] **A throw in a sheet render or cleanup is fatal:** `SheetProvider` wraps + `` (`src/index.tsx:39-43`), outside `ErrorBoundary` + (`src/screens/application/index.tsx:17`). Watch native objects in cleanups. +- [ ] **Payloads freeze at show time.** A handler in a payload keeps the closure it had + when the sheet opened, so route it through a ref + (`src/components/quickPostModal/quickPostModalContent.tsx:565-575`). +- [ ] **Missing `SheetDefinition`?** `everySheetHasDefinition` + (`src/navigation/sheets.tsx:358`) fails typecheck and names it. Keys are plain string + literals. + +## Caret on programmatic writes + +- [ ] **`setNativeProps({ text })` on its own moves the caret.** Android keeps the + caret's DISTANCE FROM THE END, so it lands inside the text just written and the next + keystroke splits it. Pass `selection` whenever the caret position after the write + matters: inserts and appends into existing text, plus full replacements of a focused + field. A reset to `text: ''` does not need it. Call sites that pass it: + `src/components/quickPostModal/quickPostModalContent.tsx:559` and `:604`, plus + `src/components/markdownEditor/view/markdownEditorView.tsx:371`. + +## Editor teardown order + +- [ ] **Pending work drains before the save, not in a child cleanup.** + `componentWillUnmount` runs in the commit phase ahead of descendant effect cleanups, + so the screen calls `flushPendingEditorWork()` then `_saveDraftToDB()` + (`src/screens/editor/screen/editorScreen.tsx:116-127`). Register new deferred editor + work via `registerPendingFlush` + (`src/components/uploadsGalleryModal/mediaInsertQueue.ts:33`), not a local cleanup. + +## Safe area + +- [ ] **`edges` REPLACES the defaults, it does not add to them.** `edges={['bottom']}` + removes the top inset. The top inset is generally the screen's job: a screen + rendering `BasicHeader` wraps it in its own `SafeAreaView` + (`src/components/basicHeader/view/basicHeaderStyles.ts:13`). + +## Notification routing + +Notification type strings are matched in more than one place, whose spellings do NOT +agree. A new type may need adding in several of them. + +- [ ] Tap routing: the switch at + `src/screens/application/hook/useInitApplication.tsx:222-302`; its `default` does + nothing. +- [ ] Websocket to FCM bridge: the allowlist at + `src/screens/application/container/applicationContainer.tsx:891-901` admits + `mention`, `reply`, `transfer`, `delegations`, `scheduled_published`, `payouts`, + `account_update` and `weekly_earnings`. Each one also needs a case in the title/body + switch at `:914`, whose `default` announces a bare `@source`. +- [ ] Foreground banner: the allowlist at + `src/components/foregroundNotification/foregroundNotification.tsx:51-58` admits + `reply`, `mention`, `transfer`, `delegations` and `scheduled_published`; anything + else shows nothing. Its own `_onPress` (`:127`) routes `transfer` and `delegations` + to the wallet, everything else to a post. + +Mind the singular/plural split: tap routing matches `favorite` and has no payout case +at all, the list rendering (`src/utils/notificationImage.ts:12`, +`src/components/notificationLine/view/notificationLineView.tsx:138`) matches `favorites` +and `payouts`, while the websocket allowlist has `payouts` but no `favorites`. + +## SDK, queries, styling, i18n + +- [ ] Broadcast mutation wrappers are thin: `useMutationAuth()` from + `src/providers/sdk/mutations/common.ts`, then the SDK hook. No key decryption, no + HiveSigner/HiveAuth branching in one; the adapter owns that. The documented + exceptions are not broadcasts, so do not report them: `useGenerateImageMutation.ts` + and the digest hooks in `useNewsletterDigestMutations.ts` bind the HiveSigner `code` + from `useAuth()`, while `useClaimPointsMutation.ts` decrypts + `currentAccount.local.accessToken` into a REST access token. A new wrapper that + reaches for keys without a non-broadcast reason is still a finding. +- [ ] Optional query params need `enabled: !!param`. +- [ ] Mobile-only keys come from `QUERIES`, the DEFAULT export of + `src/providers/queries/queryKeys.ts`; SDK-owned data uses `QueryKeys` from + `@ecency/sdk`. +- [ ] DMCA lists are set once by `ConfigManager.setDmcaLists` in + `src/providers/queries/sdk-config.ts:69`; a hand-rolled filter in a query is a + finding. +- [ ] `vestsToHp(vests, hivePerMVests)` takes TWO args and returns `0` when either is + falsy (`src/utils/conversions.ts`), so a missing rate renders a silent 0. +- [ ] Colors come from theme vars: `'$primaryBackgroundColor'` inside + `EStyleSheet.create` or `EStyleSheet.value('$primaryBlue')` at runtime. A literal hex + is a finding when it shadows a var, above all one that differs between + `src/themes/lightTheme.ts` and `src/themes/darkTheme.ts`. Deliberately + theme-independent chrome is not a finding, e.g. the black media backgrounds in + `src/screens/waves/styles/wavesReels.styles.ts`, so flag literals on a surface that + should follow the theme. `$white` is `#1e2835` in the dark theme, `$pureWhite` stays + white in both. +- [ ] Text via `intl.formatMessage({ id: 'section.key' })`, key added to the NESTED + `src/config/locales/en-US.json`; ids are dotted only because `flattenMessages` + flattens the tree in `src/index.tsx`. +- [ ] Redux reads use `useAppSelector` (`src/hooks/index.ts:6`, a + `TypedUseSelectorHook` alias) plus a memoized selector from + `src/redux/selectors`. The finding is an inline lambda picking state apart, not the + hook name. Handlers are `_`-prefixed. + +## Report + +Group as inline (must fix), outside-diff (should fix), nitpick. Per finding: +`**[BUG|SECURITY|PERF|STYLE|NITPICK]** file:line`, then what is wrong, why it matters +and the fix. Gate on `yarn lint`, `yarn typecheck` and `yarn test:ci`. diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 6f01b35227..ca35aba244 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -1,146 +1,177 @@ --- name: debug -description: Debug common issues in the Ecency mobile app with known solutions and investigation patterns +description: Diagnose a bug in the Ecency mobile app when a broadcast or login fails, a wallet or transfer amount is wrong, a screen or deep link does not open, a bottom sheet misbehaves, dark mode colors are wrong, an SDK query returns stale data, or a Metro or native build breaks argument-hint: [issue-description] -disable-model-invocation: true --- # Debug Guide -Investigate and fix issues in the Ecency mobile app. Start by identifying the category. +Triage procedure. Layout, commands, architecture and test setup are in `CLAUDE.md`; this file +adds the per-area entry points plus the traps. Verify against the code before acting. + +## 1. Auth / broadcast + +`authType` is a **string**, not a number (`src/constants/authType.ts`): +`steemConnect`, `hiveAuth`, `masterKey`, `activeKey`, `memoKey`, `postingKey`, `ownerKey`. +`mapAuthTypeToLoginType` (`src/utils/authMapper.ts`) maps them to the SDK login type: + +| `currentAccount.local.authType` | login type | +|---|---| +| `'steemConnect'` | `'hivesigner'` | +| `'hiveAuth'` | `'hiveauth'` | +| the key types above | `'key'` | +| anything else | `'key'` plus an `[AuthMapper] Unknown authType` warning | + +(CLAUDE.md still describes `AUTH_TYPE` as numbers; the code uses the strings above.) + +Routing is `src/providers/sdk/mobilePlatformAdapter.ts`. `getLoginType(username, authority)` can +override the map: a key user with no `postingKey` but an `accessToken` goes to HiveSigner. A +HiveSigner user asking for `active` returns `null`, so the SDK falls through to +`showAuthUpgradeUI`. + +Authority per operation: `resolveOperationAuthority` / `resolveTxRequiredAuthority` in +`src/utils/hiveOperationAuthority.ts`. `vote`, `comment`, `comment_options`, `delete_comment`, +`claim_reward_balance` are posting outright. The payload dependent ops are checked before that +set: `custom_json` is posting unless it declares a non-empty `required_auths`; `account_update2` +is posting unless it sets a non-empty `json_metadata` or any of +`owner`/`active`/`posting`/`memo_key`. Everything else is active. + +`resolveTxRequiredAuthority` then collapses a whole transaction to a single authority, returning +active when any one operation needs it, so the signer decrypts one key for the batch. That is +fine for a uniform transaction. It is wrong for a mixed one, meaning a batch holding both a +posting-only operation and an active operation. Hive's `verify_authority` checks the required +posting authorities, then calls `clear_approved()` before checking the required active ones, so +the two sets are satisfied independently and one active signature does not cover both. HF28 +lifted the old ban on mixing them in a single transaction, so such a batch can now arrive from a +deep link where it previously could not. Mobile signs with one key, so treat a mixed batch as +unsupported by this client, the same as a mixed `custom_json`. + +- **Active key gone right after upgrade**: `setTempActiveKey` expires it on a timer while + `getActiveKey` calls `clearTempActiveKey()` on read, so it is single use. +- **HiveSigner WebView not opening**: `broadcastWithHiveSigner` calls + `RootNavigation.navigate({ name: ROUTES.MODALS.HIVE_SIGNER, ... })` + (`src/navigation/rootNavigation.tsx`). +- **HiveAuth not responding**: `broadcastWithHiveAuth` delegates to `handleHiveAuthFallback` in + `src/providers/hive/hive.ts`. CLAUDE.md still points at `src/providers/hive/dhive.ts`; that file + is gone and nothing imports `hive/dhive`. The fallback dedupes by + `` `${name}:${operationName}` ``, so a concurrent call reuses the in-flight promise. +- **Auth upgrade sheet not showing**: `showAuthUpgradeUI` loads `SheetManager` plus `SheetNames` + via `getSheetDeps()`, a cached lazy `require()` deliberately used instead of `import()` + (which Metro wraps in an async shim), to dodge a circular import. Check that first. +- **"@ecency.app doesn't have permission to broadcast"**: + `isMissingEcencyPostingAuthorityError` lowercases the error text then matches the substring + `permission to broadcast`, or `unauthorized_client` together with an `ecency.app` mention; an + `ecency.app` mention on its own matches neither branch. A bare `unauthorized_client` is an + expired token or wrong scope. `shouldPromptPostingAuthority` gates the grant sheet. + +## 2. Wallet / transfer + +Screens `src/screens/transfer/screen/`; hooks `src/providers/queries/walletQueries/`, which +composes SDK options (`getPortfolioQueryOptions`, `getPointsQueryOptions`, +`get{Hive,Hbd,HivePower}AssetTransactionsQueryOptions`, `getOpenOrdersQueryOptions`, +`getRecurrentTransfersQueryOptions`, `getSavingsWithdrawFromQueryOptions`, +`getConversionRequestsQueryOptions`, `getCollateralizedConversionRequestsQueryOptions`). + +- Delegations are `getVestingDelegationsQueryOptions(username, limit)` (`delegateScreen.tsx`, + `src/screens/assetDetails/children/delegationsModal.tsx`). The SDK also exports + `getHivePowerDelegatingsQueryOptions`, which mobile does not appear to use, so do not reach for + it by name. +- **Shows 0 HP**: `vestsToHp(vests, hivePerMVests)` (`src/utils/conversions.ts`) returns `0` when + either argument is falsy and runs `parseFloat(String(vests))`, so a raw number and + `"1000000.000000 VESTS"` both work. Zero almost always means `hivePerMVests` was missing. +- **Stale delegations**: invalidate the exact + `getVestingDelegationsQueryOptions(name, limit).queryKey`; a different `limit` is another key. +- **`[object Object]`**: RPC rejections are often not `Error` instances, so `String(error)` + collapses them. See `src/components/upvotePopover/container/upvotePopover.tsx`. + +## 3. Navigation + +`src/navigation/`: `stackNavigator.tsx`, `drawerNavigator.tsx`, `botomTabNavigator.tsx` (spelling +is intentional), `appNavigator.tsx`, plus `rootNavigation.tsx` for non-React navigation. + +- **Screen not found**: the route must be in `src/constants/routeNames.ts` *and* registered in one + of the navigators. `stackNavigator.tsx` holds both `` and `` + entries: the root stack mounts the main stack (`STACK.MAIN`, which renders + `MainStackNavigator`) and registers routes beside it (`SCREENS.REGISTER`, `LOGIN`, `WELCOME`, + `SCREENS.ACCOUNT_LIST`, `WEB_BROWSER`, `PINCODE`, `MODALS.POLL_WIZARD`, `MODALS.HIVE_SIGNER`), + so grepping only for `MainStack` wrongly declares login, pincode, web browser and the HiveSigner + modal unregistered. Remaining routes are the `` entries in `botomTabNavigator.tsx` + and `` in `drawerNavigator.tsx`. +- **Deep link dead**: `src/hooks/useLinkProcessor.tsx` returns `handleLink`, which dispatches to + `_handleEcencyAuthTransferDeeplink`, `_handleEcencyLoginDeeplink`, + `_handleEcencyTransferDeeplink`, `_handleHiveUri` (which defers to `_handleHiveUriTransaction`) + or else `_handleDeepLink`. That last one runs `deepLinkParser` then navigates, falling back to + `ROUTES.SCREENS.WEB_BROWSER` when nothing parses, so an unrecognised link looks like the in-app + browser opening for no reason. Parsing is `src/utils/deepLinkParser.ts`, which has a co-located + test to reproduce against. + +## 4. Bottom sheets + +Registry `src/navigation/sheets.tsx`: the `SheetNames` enum and the `registerSheet` calls line up +one-to-one. + +- **Not opening**: the component must be imported into `sheets.tsx` and registered. It need not + come from the `src/components/index.tsx` barrel; some registered sheets are imported by direct + path instead, for example `SignConfirmSheet` from `src/screens/dappBrowser/components/`. +- **Stale data**: sheets unmount on hide (CLAUDE.md), so sheet state resets between shows. What a + sheet renders is the payload captured when `SheetManager.show` ran, so re-show with fresh data. +- **Falsy result**: a sheet resolves with what it passes to + `SheetManager.hide(sheetId, { payload: value })` (`src/components/authUpgradeSheet/`), so a + falsy result does not mean confirmed. It also may not say why: `SignConfirmSheet` routes both + its Cancel button and its `onClose`, which fires on a backdrop or gesture dismiss, through the + same `_close(false)`, so `!ok` lumps an explicit reject in with a dismissal. Bail out on falsy; + resolve a named field when the caller has to tell the two apart: + `const ok = await SheetManager.show(SheetNames.SIGN_CONFIRM, { payload }); if (!ok) return;` +- A throw from a sheet render or cleanup is fatal: sheets sit outside the ErrorBoundary. + +## 5. Theme + +`react-native-extended-stylesheet` is built by +`EStyleSheet.build(isDarkTheme ? darkTheme : lightTheme)` inside a `useMemo` keyed on +`[isDarkTheme]` (`src/screens/application/hook/useInitApplication.tsx`), so it reruns when the +theme toggles. Stylesheet values therefore re-resolve; a value read outside a stylesheet can stay +stale. For those reads use `EStyleSheet.value('$theme') === 'darkTheme'`. -## Issue Categories - -### 1. Authentication / Broadcast Failures - -**Auth methods**: key (direct), hivesigner (OAuth WebView), hiveauth (keychain app) - -**Investigation**: -1. Check which auth method: look at `currentAccount.local.authType` — values: 1 (posting key), 2 (active key), 5 (HiveSigner), 7 (HiveAuth) -2. Check `src/providers/sdk/mobilePlatformAdapter.ts` — all auth routing happens here -3. Check authority level: posting vs active — see operation type - -**Known issues**: -- **HiveSigner WebView not opening**: Check `broadcastWithHiveSigner` in mobilePlatformAdapter.ts — uses `RootNavigation.navigate` to HIVE_SIGNER modal -- **HiveAuth keychain not responding**: Check `broadcastWithHiveAuth` → `handleHiveAuthFallback` in dhive.ts -- **Auth upgrade sheet not showing**: `showAuthUpgradeUI` uses lazy-loaded SheetManager — check circular import issues -- **Active key not found after upgrade**: Temp active key expires after 60 seconds (`_tempActiveKeyTimer` in mobilePlatformAdapter.ts) -- **"Missing required posting authority"**: HiveSigner token expired or user revoked posting authority for ecency.app - -**Auth type mapping** (`src/utils/authMapper.ts`): -``` -AUTH_TYPE 1 (posting key) → 'key' -AUTH_TYPE 2 (active key) → 'key' -AUTH_TYPE 5 (HiveSigner) → 'hivesigner' -AUTH_TYPE 7 (HiveAuth) → 'hiveauth' -``` - -### 2. Wallet / Transfer Issues - -**Investigation**: -1. Transfer screens: `src/screens/transfer/screen/` (transferScreen, delegateScreen, etc.) -2. Wallet queries: `src/providers/queries/walletQueries/` -3. SDK wallet queries: `@ecency/sdk` — `getVestingDelegationsQueryOptions`, `getHivePowerDelegatingsQueryOptions`, etc. - -**Known issues**: -- **Amount validation inconsistent**: Check that _all_ code paths validate both minimum AND maximum (available balance) -- **Vesting shares format**: `vestsToHp()` expects string like "123.456789 VESTS" — don't pass raw numbers -- **Stale delegation data**: After delegating, invalidate the vesting delegations query cache -- **"[object Object]" error messages**: Error objects need `.message` extraction before display - -### 3. Navigation Issues - -**Stack**: React Navigation v6 with nested navigators -- Stack: `src/navigation/stackNavigator.tsx` -- Drawer: `src/navigation/drawerNavigator.tsx` -- Bottom tabs: `src/navigation/botomTabNavigator.tsx` -- Root navigation (non-React): `src/navigation/rootNavigation.ts` - -**Known issues**: -- **Screen not found**: Check route is in `src/constants/routeNames.ts` AND registered in stackNavigator -- **Params not updating**: React Navigation caches screen params — use `route.params` not stale state -- **Deep link not working**: Check `src/hooks/useLinkProcessor.tsx` for URL scheme handling - -### 4. Sheet (Bottom Sheet) Issues - -**System**: `react-native-actions-sheet` -**Registry**: `src/navigation/sheets.tsx` - -**Known issues**: -- **Sheet shows stale data**: Sheets stay mounted — state persists between show/hide cycles. Add `useEffect(() => { reset() }, [payload])` to reset state -- **Sheet not opening**: Check it's registered in `sheets.tsx` AND exported from `src/components/index.tsx` -- **Return value is undefined**: Backdrop tap returns undefined. Always handle: `const result = await SheetManager.show(...); if (!result) return;` - -### 5. Styling / Theme Issues - -**System**: `react-native-extended-stylesheet` -**Themes**: `src/themes/lightTheme.ts`, `src/themes/darkTheme.ts` - -**Known issues**: -- **Colors wrong in dark mode**: Using hardcoded colors instead of `$primaryBackgroundColor`, `$primaryBlack`, etc. -- **Theme not applied**: EStyleSheet variables are set at app startup. If a color doesn't change with theme, check it uses a `$variable` - -**Key theme variables**: | Variable | Light | Dark | |---|---|---| -| `$primaryBackgroundColor` | `#FFFFFF` | `#1E2835` | -| `$primaryBlack` | `#3c4449` | `#F5F5F5` | -| `$primaryLightBackground` | `#f6f6f6` | `#131e29` | -| `$iconColor` | `#788187` | `#F5F5F5` | - -### 6. SDK Query Issues - -**Investigation**: -1. SDK config: `src/providers/queries/sdk-config.ts` -2. Query client: `src/providers/queries/index.ts` -3. SDK queries used in: `src/providers/queries/` and directly in screens +| `$primaryBackgroundColor` | `#FFFFFF` | `#1e2835` | +| `$primaryLightBackground` | `#f6f6f6` | `#2e3d51` | +| `$primaryBlack` | `#3c4449` | `#fcfcfc` | +| `$primaryDarkText` | `#788187` | `#fcfcfc` | +| `$iconColor` | `#c1c5c7` | `#788187` | -**Known issues**: -- **Query not fetching**: Check `enabled` flag — undefined params cause silent no-fetch -- **Stale data after mutation**: Mutation should invalidate relevant queries via `invalidateQueries` in adapter -- **RPC node errors**: SDK handles failover automatically via `ConfigManager.setHiveNodes()` +`$primaryGray`, `$primaryLightGray`, `$primaryRed`, `$primaryGreen` are identical in both themes, +so switching to them fixes nothing. Bad dark mode colors usually mean a literal hex. -### 7. Build Issues +## 6. SDK queries -**Android**: -```bash -# Gradle patch required for RN 0.79.5 -bash patch-gradle.sh +Config `src/providers/queries/sdk-config.ts` (`initSdkConfig`), client +`src/providers/queries/index.ts`. -# Clean build -cd android && ./gradlew clean && cd .. -yarn android -``` +- **No fetch**: check `enabled`; an undefined username usually disables the query. +- **Stale after a mutation**: the adapter's `invalidateQueries` takes a raw key or `{ queryKey }` + and warns instead of throwing on failure, so a wrong key looks like success. +- **RPC errors**: the node pool reaches the SDK through `ConfigManager.setHiveNodes(...)`, which + runs from more than one call site, so confirm which list won before blaming failover. Denied + nodes are dropped by `withoutBlockedServers` / `isBlockedServer` + (`src/constants/options/api.ts`), so check the pool too. -**iOS**: -```bash -# Reinstall pods -cd ios && pod install && cd .. -yarn ios -``` +## 7. Build -**Metro bundler**: ```bash -# Full cache clear -yarn clear -# Or just reset Metro cache -yarn start --reset-cache +bash patch-gradle.sh # gradle patch, also runs on install +cd android && ./gradlew clean && cd .. && yarn android +cd ios && pod install && cd .. && yarn ios +yarn start --reset-cache # Metro cache only ``` -## General Investigation Steps +`yarn clear` deletes `node_modules` and reinstalls, so never run it in a shared or worktree +checkout. `yarn typecheck` runs `scripts/typecheck.js`, not bare `tsc`. -1. **Reproduce**: Identify exact steps and which screen/component is affected -2. **Find the code**: Screens in `src/screens/`, components in `src/components/` -3. **Check the data layer**: SDK query → query hook → component -4. **Check auth flow**: mobilePlatformAdapter → SDK broadcast → HiveSigner/HiveAuth/key signing -5. **Check Redux**: `useAppSelector(selectCurrentAccount)` for user state -6. **Check logs**: Reactotron for network + state, `console.log` for quick debugging +## Triage order -## Useful Commands - -```bash -yarn lint # Check for lint errors -yarn lint:fix # Auto-fix lint issues -yarn start --reset-cache # Clear Metro cache -adb reverse tcp:9090 tcp:9090 # Reactotron Android -``` +1. Reproduce, name the screen or component, find it under `src/screens/` or `src/components/`. +2. Reads: SDK query options to query hook to component. +3. Writes: `useMutationAuth()` to `mobilePlatformAdapter` to HiveSigner / HiveAuth / key. +4. User state: `useAppSelector(selectCurrentAccount)`. +5. Prefer a co-located Jest test over a manual repro; `src/utils/` already has suites.