From 41ce5ea9b3ec74f456acb5526d9425d792d463d4 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 11:06:11 +0000 Subject: [PATCH 01/11] chore(skills): correct the repo skills against the current code The six skills in .claude/skills were last touched in the repository rename on 2026-08-05, while the code they describe moved on. - add-sheet presented a falsy return as a safe cancellation signal. The library substitutes the show payload for a falsy result only when a sheet forwards its own `payload` prop to ``, which nothing here does, so falsy returns survive today. The skill now states that condition, then gives the real reason for the named-field convention: a dismissal and a deliberate `false` are indistinguishable by truthiness. - debug documented numeric auth types 1, 2, 5 and 7. `src/constants/authType.ts` holds string constants. - add-feature used a plain `View` as the screen root while 41 screens use `SafeAreaView`, reproducing the inset bug fixed in #3531. It also omitted `src/navigation/types.ts`, where a missing route entry is a compile error. - add-sheet, code-review and debug all claimed sheets stay mounted, which CLAUDE.md already records as false. code-review now carries this repo's own shipped-bug traps rather than generic React advice. Every concrete claim in all six files was re-verified against the current code. Dropping disable-model-invocation makes the skills discoverable. With the flag they were loaded but hidden from the model's skill list. Closes #3533 --- .claude/skills/add-feature/SKILL.md | 260 ++++++++++++------------- .claude/skills/add-mutation/SKILL.md | 160 ++++++++-------- .claude/skills/add-query/SKILL.md | 210 ++++++++++++--------- .claude/skills/add-sheet/SKILL.md | 273 ++++++++++++++------------- .claude/skills/code-review/SKILL.md | 200 ++++++++++++-------- .claude/skills/debug/SKILL.md | 267 ++++++++++++++------------ 6 files changed, 723 insertions(+), 647 deletions(-) diff --git a/.claude/skills/add-feature/SKILL.md b/.claude/skills/add-feature/SKILL.md index 6377560858..883976523c 100644 --- a/.claude/skills/add-feature/SKILL.md +++ b/.claude/skills/add-feature/SKILL.md @@ -1,196 +1,180 @@ --- 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. Redux, TanStack Query, `@ecency/sdk`, sheets and lint rules +are covered in CLAUDE.md and not repeated. Two steps are easy to miss and each one breaks something: +the safe-area root (Step 1) and the params contract (Step 4). -## Screen Structure Patterns - -The app uses two patterns for screens: - -### Pattern 1: Class Component (Legacy — existing screens) - -Many existing screens use class components with container/view separation: +New screens are functional. Only 12 of the 142 `.tsx` files under `src/screens/` are classes: 11 +legacy holdouts, plus `application/children/errorBoundary.tsx`, which React requires to be a class. +A dismissible overlay is a bottom sheet instead: `src/navigation/sheets.tsx`, see CLAUDE.md. ``` 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; 8 .tsx files inline EStyleSheet.create + children/ hooks/ # optional ``` -Example: `src/screens/transfer/screen/delegateScreen.tsx` - -### Pattern 2: Functional Component (Preferred for new screens) +## Step 1: screen rooted in SafeAreaView -New screens should use functional components with hooks: - -``` -src/screens// - screen/Screen.tsx # Functional component - children/ # Sub-components - hooks/ # Custom hooks -``` +Root must be `SafeAreaView` from `react-native-safe-area-context`: 40 files under `src/screens/` +import it from there. The one file that still takes `SafeAreaView` from `react-native` is +`src/screens/dappBrowser/screen/dappBrowser.tsx`, 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 +Email digests; the fix to that screen in PR #3531 was the single-line deletion of +`edges={['bottom']}`. -## Step 1: Create the Screen +Skeleton for `screen/Screen.tsx` (a template, not a quote of any one file): -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 (81 call sites); `useAuth()` from +`src/hooks` (45) 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. A hex literal breaks dark mode. -```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` pulls its screens from that barrel, so a screen +missing from it cannot be registered there. `src/screens/waves` shows the cost of skipping this: it +never reached the barrel, so `botomTabNavigator.tsx` has to reach 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) -In `src/navigation/stackNavigator.tsx`: +`src/navigation/types.ts` derives `RouteName` from ROUTES, then asserts every route has an entry: -```typescript -import FeatureScreen from '../screens//screen/Screen'; +```ts +export type _MissingRouteContracts = AssertNever>; +``` + +A ROUTES entry with no `AppParamList` entry is a compile error: `TS2344: Type +'"MyNewFeatureScreen"' does not satisfy the constraint 'never'`, plus five cascading `TS2536: Type +'K' cannot be used to index type 'AppParamList'` from the mapped types above it. `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 every call site, 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` holds two. `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 the 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 15 `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. 25 files under `src/screens/` destructure a +`route` prop, usually typed `any`; only two call `useRoute`, one of them with a locally declared +`RouteProp` (`dappBrowser.tsx`). Prefer keying off Step 4 instead. No screen does this yet: + +```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` (53 sites). + +## Step 7: strings + +`src/config/locales/en-US.json` is the only catalog you edit; Crowdin owns the other 38. It is +**nested**: all 92 top-level keys are objects, none contains a dot. `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..98f1139bec 100644 --- a/.claude/skills/add-mutation/SKILL.md +++ b/.claude/skills/add-mutation/SKILL.md @@ -1,120 +1,108 @@ --- 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 only the procedure. -## 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`. 40 of the 47 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: the last positional arg after `authContext`, so the + third arg in 40 wrappers but the fourth in the two community ones. Pass it unless the + hook has no such parameter. `useBroadcastMutation` takes it as + `{ broadcastMode: 'async' }` inside the options object instead. +- `useMutationAuth()` from `./common.ts` (44 of 47 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, only the factory. +- Wrappers take no arguments. Three differ. `useSetCommunityRoleMutation(community)` plus + `useUpdateCommunityMutation(community)` take the community because the SDK bakes it into + the mutation key. `useAccountRelationsUpdateMutation(target, onSuccess, onError)` takes + three because the SDK bakes the target plus both callbacks into the mutation options. +- Three of the 47 files are not broadcasts, so they skip `useMutationAuth`. + `useGenerateImageMutation` plus the three 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. +Without it the hook is not importable: ```typescript -export { useMutation } from './useMutation'; +export { useTransferMutation } from './useTransferMutation'; ``` -## Step 3: Use in a Screen/Component +## 3. Call it (from the barrel, never the file) ```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 - -If the operation isn't in `@ecency/sdk` yet, create it there first: - -Location: `packages/sdk/src/modules//mutations/use-.ts` +There is no `packages/sdk` here. `@ecency/sdk` is an npm dependency (`^2.3.93`), so there +is no local build step. Use the generic `useBroadcastMutation`, as +`useIgnoreUserMutation.ts` does. Seven positional args: ```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 - } - ); -} +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 +); ``` -Then rebuild SDK: `cd ../vision-web && pnpm --filter @ecency/sdk build` +Pass authority as a plain lowercase string. The parameter is typed `AuthorityLevel` from +`@ecency/sdk` (`'posting' | 'active' | 'owner' | 'memo'`); mobile wrappers only ever 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, account_update, account_update2 -## Authority Levels +The SDK's exported `OPERATION_AUTHORITY_MAP` is the reference list. It maps both +`account_update` plus `account_update2` to `active`. `useBroadcastMutation` never consults +that map: its `authority` parameter just defaults to `'posting'`, so always 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 (60s temp key). +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..5034b804da 100644 --- a/.claude/skills/add-query/SKILL.md +++ b/.claude/skills/add-query/SKILL.md @@ -1,136 +1,162 @@ --- 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` 2.3.93 exports **165** `get*QueryOptions` helpers. 25 of the 32 non-test files under +`src/providers/queries/` import from `@ecency/sdk`; only **2** `queryFn:` remain in that whole +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 all 165. The last grep gives the real argument order. +Never guess it. `getPostQueryOptions(author, permlink?, observer?, num?)` takes the observer third. +13 of its 14 call sites pass one. + +## 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. 47 spread sites across `src/`. +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 every other surface. + +## 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()` (12 query files do), +never re-derive 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 +hand-rolls those two exactly once out of 18 `useInfiniteQuery` calls. 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. 10 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, one of the two survivors, +`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'` (16 of them). Its only +named re-export is `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, for example `export { postQueries, wavesQueries, pollQueries };`. + +## Gotchas + +1. **Persistence is an allowlist.** `_shouldDehydrateQuery` in `src/providers/queries/index.ts` + switches on `queryKey[0]`, then narrows on `queryKey[1]`. Only `core`, `get-account-full` plus + `points` persist wholesale. `posts`, `accounts`, `notifications` persist part of their subtypes: + `accounts` returns false unless the subtype is `bookmarks` or `favorites`, `posts` drops `entry`, + `notifications` drops `announcements`. Everything else is dropped, so a new namespace or subtype + is not persisted until you add its case. Read the switch before assuming a new key persists. + Infinite lists persist only while a single page is loaded. +2. **Guard with `enabled`** whenever a param can be undefined: 27 uses under `providers/queries` + (`grep -rnE "^[[:space:]]*enabled[,:]" src/providers/queries | wc -l`). An `undefined` anywhere + in a query key also blocks persistence. +3. Returning `undefined` from `getNextPageParam` stops pagination. `null` stops it too on the + installed TanStack Query 5.83.0, whose `hasNextPage` tests `!= null`, but the repo's one + hand-rolled case returns `undefined`. +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. +5. **Non-React code**: import `getQueryClient` from the app barrel `providers/queries` (19 sites) + rather than the SDK (5): `await queryClient.fetchQuery(getAccountsQueryOptions([username]))`. +6. `src/providers/queries/sdk-config.ts` runs once from `initQueryClient()` and configures + `ConfigManager` (query client, private API host, image host, Hive nodes, DMCA lists). Adding a + query never requires touching it. + +Prettier width is 100 (`.prettierrc`). Finish with `yarn lint` plus `yarn typecheck`; the baseline +in `tsc-baseline.json` is empty, so any type error fails CI. diff --git a/.claude/skills/add-sheet/SKILL.md b/.claude/skills/add-sheet/SKILL.md index 99abf35170..a73bc0bab7 100644 --- a/.claude/skills/add-sheet/SKILL.md +++ b/.claude/skills/add-sheet/SKILL.md @@ -1,181 +1,198 @@ --- 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 every sheet here follows. -## 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`). + +Nothing in `src/` forwards that prop down into ``: `grep -rn "payload=" src/` returns +zero hits. The only spreads onto an `` are narrow literals such as +`{...({ hideUnderlay: true } as any)}`. So `payloadRef.current` is `undefined` for every sheet +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:101` is the 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 the sheet 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: 16 files type themselves with +`SheetProps<'...'>` and 12 of those sit at that path. Nine more 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 { 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. 10 files 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 [value, setValue] = useState(''); + const closedRef = useRef(false); + + const _reset = useCallback(() => { + closedRef.current = false; + setValue(''); + }, []); + + // onBeforeShow is the authoritative reset: it fires on every fresh presentation. + // This effect covers the one case it misses, 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', - }, -}); - -export default MySheet; ``` -## Step 2: Create Index File - -Location: `src/components//index.ts` - -```typescript -export { default as MySheet } from './'; -``` +Strings via `useIntl`. Colors via EStyleSheet theme variables from `src/themes/` +(`$primaryBackgroundColor`, `$primaryBlack`, `$primaryDarkGray`, `$iconColor`), never a hex. -## Step 3: Export from Components +## Step 2: Folder index -Add to `src/components/index.tsx`: +`src/components//index.ts`. 13 component folders re-export their sheet with the first +line; the 5 that also publish a result type (`modNotesSheet`, `communityManageSheet`, +`communityRoleEditSheet`, `searchFiltersSheet`, `newsletterDigestSheet`) 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 7 of the 29 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]`** (29 literal keys, 0 computed): string enum member +types are nominal, so with computed keys `keyof Sheets` accepts only enum members and every +`SheetProps<'my_sheet'>` fails with TS2344. -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' }, -}); - -if (result) { - // User confirmed -} else { - // User cancelled (backdrop tap or explicit cancel) -} -``` - -## Step 6: Add i18n Strings +const result = await SheetManager.show(SheetNames.MY_SHEET, { payload: { someParam: 'x' } }); -In `src/config/locales/en-US.json`: - -```json -{ - "my_sheet.title": "Sheet Title", - "my_sheet.confirm": "Confirm", - "my_sheet.cancel": "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; ``` -## Styling Notes - -- 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` +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)`). -## State Reset Pattern +## Step 6: i18n strings -Sheets stay mounted. If your sheet has state, reset it when payload changes: +Edit `src/config/locales/en-US.json` only; Crowdin owns the other locales. The file is nested +objects (all 92 top-level keys, zero dotted ones) while `formatMessage` ids stay dotted: -```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 show and unmount on hide**: `SheetProvider` renders `!visible ? null : ` + (`dist/src/provider.js:156`). `useState` initials are fresh every open, so nothing stale needs + clearing on mount. Every unmount cleanup runs on every close. Source comments claiming sheets + stay mounted are stale. +- **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 freezes at show time**: the provider stores it in state at show, so a callback + passed inside a payload keeps the closure it had when the sheet opened. 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..c1694dc5c9 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -1,85 +1,129 @@ --- 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 shipped-bug traps: 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 +only traps that have already shipped bugs here. Confirm each finding against the code +on disk before reporting it. + +## Action sheets + +- [ ] **Resolve an object, gate on a named field.** This is a repo convention with a + reason, not something the library enforces. `react-native-actions-sheet` 0.9.7 + publishes `data || payloadRef.current || data` on close (`dist/src/index.js:408`) + where `payloadRef` tracks ``'s own `payload` prop + (`dist/src/index.js:87` and `:139`). The provider hands the `SheetManager.show` + payload to the registered component (`dist/src/provider.js:160`) but no sheet in + `src/` forwards it on to ``, so `payloadRef.current` is `undefined` + today and a falsy resolve survives: `if (!result) return false;` at + `src/providers/sdk/mobilePlatformAdapter.ts:317` reads a dismissal correctly. One + added `payload={payload}` on an `` would silently turn every falsy + cancel into a truthy confirm, which is why sheets resolve `{ cancelled: true }` or + `{ field: value }` instead. Six sheets document the 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 every cleanup + runs on every 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 string + literals, never `[SheetNames.X]`. + +## Caret on programmatic writes + +- [ ] **`setNativeProps({ text })` on its own moves the caret.** Android's + `updateExtraData` 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. 20 call + sites, of which 3 pass `selection`: + `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 every descendant effect + cleanup, 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`), never 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 the screen's job: a screen rendering + `BasicHeader` wraps it in its own `SafeAreaView` + (`src/components/basicHeader/view/basicHeaderStyles.ts:13`), while child components + and `Modal` bodies inherit the screen's. Modals use + `Platform.select({ ios: [], default: ['top'] })`. + +## Notification routing + +Three separate copies whose type strings do NOT match. A new type must be added to +every copy it should reach. + +- [ ] Tap routing: the switch at + `src/screens/application/hook/useInitApplication.tsx:222-302` handles 15 types + (`vote`, `unvote`, `mention`, `follow`, `unfollow`, `ignore`, `reblog`, + `scheduled_published`, `favorite`, `bookmark`, `reply`, `transfer`, `inactive`, + `spin`, `hiveuri`); its `default` does nothing. +- [ ] Websocket to FCM bridge: the allowlist at + `src/screens/application/container/applicationContainer.tsx:891-901` admits 8 types + (`mention`, `reply`, `transfer`, `delegations`, `scheduled_published`, `payouts`, + `account_update`, `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 five + (`reply`, `mention`, `transfer`, `delegations`, `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`, the list and websocket +paths match `favorites`/`payouts`. + +## SDK, queries, styling, i18n + +- [ ] Mutation wrappers are two imports plus a four-line function: `useMutationAuth()` + from `src/providers/sdk/mutations/common.ts` then the SDK hook (45 call sites). No + key decryption or HiveSigner/HiveAuth branching in one; the adapter owns that. +- [ ] Optional query params need `enabled: !!param` (38 call sites). +- [ ] Mobile-only keys come from `QUERIES`, the DEFAULT export of + `src/providers/queries/queryKeys.ts` (10 importers); 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` (262 files) or `EStyleSheet.value('$primaryBlue')` at runtime. + A literal hex in a style is a finding. +- [ ] 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` plus a selector from `src/redux/selectors` + (265 calls); a raw `useSelector` is a finding. Handlers are `_`-prefixed + (496 `const _handle*`/`const _on*` against 114 unprefixed). + +## 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` (empty baseline, any error fails CI) +and `yarn test:ci`. diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 6f01b35227..667d2ba0b0 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -1,146 +1,163 @@ --- 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**, never 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 five key types above | `'key'` | +| anything else | `'key'` plus an `[AuthMapper] Unknown authType` warning | + +(CLAUDE.md still says `AUTH_TYPE 1/2/5/7`. Those numbers appear nowhere in `src/`.) + +Routing is `src/providers/sdk/mobilePlatformAdapter.ts`. `getLoginType(username, authority)` +overrides the map twice: a key user doing an `active` op with `local.activeKey` signs directly; +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`. Posting covers only `vote`, `comment`, `comment_options`, +`custom_json`, `delete_comment`, `claim_reward_balance`; `custom_json` with `required_auths` plus +`account_update2` touching keys or `json_metadata` escalate. Everything else is active. + +- **Active key gone right after upgrade**: `setTempActiveKey` expires it after `60_000` ms 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 `error_description` plus `message` 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`, but mobile never uses it (0 hits in `src/`), 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 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 36 `` entries plus 9 + `` entries for the pre-login and modal routes (`STACK.MAIN`, + `SCREENS.REGISTER`, `LOGIN`, `WELCOME`, `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. The remaining routes are the + 5 `` in `botomTabNavigator.tsx` and `` in + `drawerNavigator.tsx`. +- **Deep link dead**: `src/hooks/useLinkProcessor.tsx` exports only `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 are +one-to-one (29 each today). + +- **Not opening**: the component must be imported into `sheets.tsx` and registered. It need not + come from the `src/components/index.tsx` barrel; 7 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 no sheet state survives a close. What a + sheet renders is the payload captured when `SheetManager.show` ran, so re-show with fresh data. +- **Result is `undefined`**: a sheet resolves with what it passes to + `SheetManager.hide(sheetId, { payload: value })` (`src/components/authUpgradeSheet/`). A + backdrop dismiss resolves `undefined`, so a falsy result means dismissed, never confirmed: + `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 the only `EStyleSheet.build` call in the repo, +`EStyleSheet.build(isDarkTheme ? darkTheme : lightTheme)` inside a `useMemo` keyed on +`[isDarkTheme]` (`src/screens/application/hook/useInitApplication.tsx`), so it reruns on every +theme toggle. Stylesheet values therefore re-resolve; only a value read outside a stylesheet stays +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 silently disables the query. +- **Stale after a mutation**: the adapter's `invalidateQueries` takes a raw key or `{ queryKey }` + and only warns on failure, so a wrong key looks like success. +- **RPC errors**: `ConfigManager.setHiveNodes(nodes)` runs once from the saved server plus + `getNodes()`, both filtered by `withoutBlockedServers` / `isBlockedServer` + (`src/constants/options/api.ts`); `hiveTxConfig.timeout` is 10000 ms. A blocked node is never + retried, so check the pool before blaming failover. -**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 # required for RN 0.79.5, 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. From f9d1b4088f4e0bd6fc671bcbedef3db3c38dad1a Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 11:36:28 +0000 Subject: [PATCH 02/11] chore(skills): address PR review findings - code-review: three rules were stated too categorically. Raw `useSelector` is fine with an already-typed selector, a literal hex color is fine when it is deliberately theme-independent. REST or token wrappers also legitimately use `useAuth()` rather than `useMutationAuth()`. - debug: `SignConfirmSheet` returns `false` from its reject control, so a falsy result is not always a dismissal. - add-sheet: the sheet template omitted its imports. - add-sheet: 11 sheet folders re-export through an index, not 13. The other two index files are not sheets. - add-feature: replaced a wrong screen count with "most", and tagged the untagged code fence for markdownlint MD040. Two findings were declined with evidence. CodeRabbit read "three of the 47 files are not broadcasts" as a hook count; it is a file count, and `grep -L useMutationAuth` returns exactly three files. CodeRabbit also restated the falsy-return substitution as unconditional. `payloadRef` tracks `ActionSheet`'s own `payload` prop, which nothing in `src/` sets, so the substitution cannot fire here. --- .claude/skills/add-feature/SKILL.md | 4 ++-- .claude/skills/add-sheet/SKILL.md | 19 +++++++++++++++--- .claude/skills/code-review/SKILL.md | 31 ++++++++++++++++++++++------- .claude/skills/debug/SKILL.md | 10 +++++++++- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/.claude/skills/add-feature/SKILL.md b/.claude/skills/add-feature/SKILL.md index 883976523c..8147836afb 100644 --- a/.claude/skills/add-feature/SKILL.md +++ b/.claude/skills/add-feature/SKILL.md @@ -14,7 +14,7 @@ New screens are functional. Only 12 of the 142 `.tsx` files under `src/screens/` legacy holdouts, plus `application/children/errorBoundary.tsx`, which React requires to be a class. A dismissible overlay is a bottom sheet instead: `src/navigation/sheets.tsx`, see CLAUDE.md. -``` +```text src/screens// index.ts # local barrel, re-exported from src/screens/index.ts screen/Screen.tsx @@ -134,7 +134,7 @@ const navigation = useNavigation(); navigation.navigate(ROUTES.SCREENS.MY_FEATURE, { username }); ``` -Reading the params back is not settled house style. 25 files under `src/screens/` destructure a +Reading the params back is not settled house style. Most screens destructure a `route` prop, usually typed `any`; only two call `useRoute`, one of them with a locally declared `RouteProp` (`dappBrowser.tsx`). Prefer keying off Step 4 instead. No screen does this yet: diff --git a/.claude/skills/add-sheet/SKILL.md b/.claude/skills/add-sheet/SKILL.md index a73bc0bab7..84ef777023 100644 --- a/.claude/skills/add-sheet/SKILL.md +++ b/.claude/skills/add-sheet/SKILL.md @@ -60,7 +60,11 @@ reason is not. `src/components/modNotesSheet/modNotesSheet.tsx`: ```typescript +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'; // Matches the SheetNames value. 10 files keep this so `hide` has an id even when the // sheet is rendered outside the registry. @@ -75,6 +79,7 @@ export interface MySheetResult { } const MySheet: React.FC> = ({ sheetId, payload }) => { + const intl = useIntl(); const [value, setValue] = useState(''); const closedRef = useRef(false); @@ -108,14 +113,22 @@ const MySheet: React.FC> = ({ sheetId, payload }) => { ); }; + +const styles = EStyleSheet.create({ + sheetContainer: { paddingHorizontal: 0, backgroundColor: '$primaryBackgroundColor' }, +}); + +export default MySheet; ``` -Strings via `useIntl`. Colors via EStyleSheet theme variables from `src/themes/` -(`$primaryBackgroundColor`, `$primaryBlack`, `$primaryDarkGray`, `$iconColor`), never a hex. +Colors come from EStyleSheet theme variables in `src/themes/` (`$primaryBackgroundColor`, +`$primaryBlack`, `$primaryDarkGray`, `$iconColor`), never a hex. 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 2: Folder index -`src/components//index.ts`. 13 component folders re-export their sheet with the first +`src/components//index.ts`. 11 sheet folders re-export their sheet with the first line; the 5 that also publish a result type (`modNotesSheet`, `communityManageSheet`, `communityRoleEditSheet`, `searchFiltersSheet`, `newsletterDigestSheet`) add the second: diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index c1694dc5c9..88c326fba3 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -99,9 +99,15 @@ paths match `favorites`/`payouts`. ## SDK, queries, styling, i18n -- [ ] Mutation wrappers are two imports plus a four-line function: `useMutationAuth()` - from `src/providers/sdk/mutations/common.ts` then the SDK hook (45 call sites). No - key decryption or HiveSigner/HiveAuth branching in one; the adapter owns that. +- [ ] Broadcast mutation wrappers are two imports plus a four-line function: + `useMutationAuth()` from `src/providers/sdk/mutations/common.ts` then the SDK hook + (44 of the 47 wrapper files). No key decryption, no HiveSigner/HiveAuth branching in + one; the adapter owns that. The other three files are not broadcasts and are the + documented exceptions, so do not report them: `useGenerateImageMutation.ts` plus the + three digest hooks in `useNewsletterDigestMutations.ts` bind the HiveSigner `code` + from `useAuth()`, while `useClaimPointsMutation.ts` derives a REST access token by + decrypting `currentAccount.local.accessToken` with `getDigitPinCode(pin)`. A new + wrapper that reaches for keys without a non-broadcast reason is still a finding. - [ ] Optional query params need `enabled: !!param` (38 call sites). - [ ] Mobile-only keys come from `QUERIES`, the DEFAULT export of `src/providers/queries/queryKeys.ts` (10 importers); SDK-owned data uses `QueryKeys` @@ -113,13 +119,24 @@ paths match `favorites`/`payouts`. falsy (`src/utils/conversions.ts`), so a missing rate renders a silent 0. - [ ] Colors come from theme vars: `'$primaryBackgroundColor'` inside `EStyleSheet.create` (262 files) or `EStyleSheet.value('$primaryBlue')` at runtime. - A literal hex in a style is a finding. + 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`: `'#357ce6'` is + `$primaryBlue` yet is written out 5 times across 4 files. Deliberately + theme-independent chrome is not a finding, e.g. the black media backgrounds in + `src/screens/waves/styles/wavesReels.styles.ts`; 27 of the 262 files already hold a + hex literal, so only flag ones on a surface that should follow the theme. `$white` is + `#1e2835` in the dark theme, `$pureWhite` is the one that stays white. - [ ] 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` plus a selector from `src/redux/selectors` - (265 calls); a raw `useSelector` is a finding. Handlers are `_`-prefixed - (496 `const _handle*`/`const _on*` against 114 unprefixed). +- [ ] Redux reads use `useAppSelector` (`src/hooks/index.ts:6`, only a + `TypedUseSelectorHook` alias) plus a memoized selector from + `src/redux/selectors` (264 calls). The finding is an inline lambda picking state + apart, not the hook name: three hooks call `react-redux`'s `useSelector` directly and + still pass a memoized selector (`src/hooks/useImageReveal.ts:14`, + `src/hooks/useContentLanguageGate.ts:80`, + `src/hooks/useTransferMutations.ts:34`), which types identically. Handlers are + `_`-prefixed (496 `const _handle*`/`const _on*` against 111 unprefixed). ## Report diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 667d2ba0b0..a9869d8047 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -106,8 +106,16 @@ one-to-one (29 each today). sheet renders is the payload captured when `SheetManager.show` ran, so re-show with fresh data. - **Result is `undefined`**: a sheet resolves with what it passes to `SheetManager.hide(sheetId, { payload: value })` (`src/components/authUpgradeSheet/`). A - backdrop dismiss resolves `undefined`, so a falsy result means dismissed, never confirmed: + backdrop dismiss resolves `undefined`, so a falsy result never means confirmed, but it does not + say why: `SignConfirmSheet` resolves `false` from its Cancel button as well as from its + `onClose`, 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;` +- The library close path publishes `data || payloadRef.current || data`, where `payloadRef` is the + `payload` **prop of ``**, not the show payload the wrapper receives. No sheet here + forwards it (0 hits for `payload=` in `src/`), so the fallback is inert and a dismissal really + does resolve `undefined`. Forward `payload` into `` and a dismissal starts resolving + that truthy payload instead, which reads as confirmed. - A throw from a sheet render or cleanup is fatal: sheets sit outside the ErrorBoundary. ## 5. Theme From 6ea0a2b7085a139516ca30f52ae8b81fc2edcdb0 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 11:42:09 +0000 Subject: [PATCH 03/11] fix(skills): account_update2 authority is payload dependent on mobile The add-mutation skill listed account_update2 as flatly active, following the SDK's OPERATION_AUTHORITY_MAP. Mobile deliberately diverges: most account_update2 broadcasts here are a profile edit or a pinned-post change that touches only posting_json_metadata, which posting authority can sign. Copying 'active' would prompt a needless active-key upgrade every time. The skill now documents the real rule from src/utils/hiveOperationAuthority.ts:37, including the custom_json special case, plus the scope: that resolver serves the hive-uri path today while mutation wrappers still pass authority literally. --- .claude/skills/add-mutation/SKILL.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.claude/skills/add-mutation/SKILL.md b/.claude/skills/add-mutation/SKILL.md index 98f1139bec..c0656b6bfc 100644 --- a/.claude/skills/add-mutation/SKILL.md +++ b/.claude/skills/add-mutation/SKILL.md @@ -89,12 +89,25 @@ Pass authority as a plain lowercase string. The parameter is typed `AuthorityLev - `'posting'`: vote, comment, reblog, follow, ignore, community roles - `'active'`: transfer, delegate, power up/down, savings, limit orders, proposal vote, - witness proxy, account_update, account_update2 - -The SDK's exported `OPERATION_AUTHORITY_MAP` is the reference list. It maps both -`account_update` plus `account_update2` to `active`. `useBroadcastMutation` never consults -that map: its `authority` parameter just defaults to `'posting'`, so always pass the right -value explicitly. + witness proxy, account_update + +The SDK's exported `OPERATION_AUTHORITY_MAP` is the reference list. It maps +`account_update2` to `'active'` flatly. Mobile deliberately does not, because most +`account_update2` broadcasts here are a profile edit or a pinned-post change that touches +only `posting_json_metadata`, which posting authority can sign. Forcing `'active'` would +prompt a needless active-key upgrade every time. + +`src/utils/hiveOperationAuthority.ts:37` holds the real rule: `account_update2` resolves to +`'posting'` unless the payload also sets `owner`, `active`, `posting`, `memo_key`, or a +non-empty `json_metadata`, in which case it is `'active'`. `custom_json` is the other special +case, active only when it declares `required_auths`. `src/utils/hiveOperationAuthority.test.ts` +pins every branch. + +That resolver currently serves the hive-uri path only (`src/providers/hive/hive.ts:750` and +`src/hooks/useLinkProcessor.tsx:648`); mutation wrappers still pass authority literally. So if +you write an `account_update2` wrapper, decide from the payload rather than copying `'active'` +out of the SDK map. `useBroadcastMutation` never consults that map either: its `authority` +parameter defaults to `'posting'`, so always pass the right value explicitly. Prefer an SDK `buildOp` helper (`buildTransferOp`, `buildVoteOp`) over a hand written op tuple. From 73cec033fcc221dbf2cdc981a8789145e4d7b6b9 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 12:01:43 +0000 Subject: [PATCH 04/11] fix(skills): owner authority plus per-payload authority limits Two problems with the account_update2 guidance added in 6ea0a2b. An account_update2 that sets owner requires OWNER authority, not active. Hive's own test matrix has both an active-signed update plus a posting-signed update failing (hive issue 520). The skill now carries a three-row table. src/utils/hiveOperationAuthority.ts implements only the first two rows: it is typed to return 'posting' | 'active', so its owner branch returns 'active', and the test file has no owner case. Since that resolver serves the hive-uri path, where operations arrive from an external link, a deep link changing owner is signed with the wrong key today. Mobile has no owner signing path at all: the adapter does not implement getOwnerKey, so the SDK's case 'owner' throws. The skill now says to reject an owner change rather than route it to active. "Decide from the payload" was also not implementable. useBroadcastMutation takes authority as its sixth positional parameter, fixed when the hook is created, while operations is (payload) => Operation[] and runs at mutate time. The skill now prescribes two hooks with fixed authorities, chosen at the call site. --- .claude/skills/add-mutation/SKILL.md | 60 ++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/.claude/skills/add-mutation/SKILL.md b/.claude/skills/add-mutation/SKILL.md index c0656b6bfc..dc5dd16a3d 100644 --- a/.claude/skills/add-mutation/SKILL.md +++ b/.claude/skills/add-mutation/SKILL.md @@ -92,22 +92,50 @@ Pass authority as a plain lowercase string. The parameter is typed `AuthorityLev witness proxy, account_update The SDK's exported `OPERATION_AUTHORITY_MAP` is the reference list. It maps -`account_update2` to `'active'` flatly. Mobile deliberately does not, because most -`account_update2` broadcasts here are a profile edit or a pinned-post change that touches -only `posting_json_metadata`, which posting authority can sign. Forcing `'active'` would -prompt a needless active-key upgrade every time. - -`src/utils/hiveOperationAuthority.ts:37` holds the real rule: `account_update2` resolves to -`'posting'` unless the payload also sets `owner`, `active`, `posting`, `memo_key`, or a -non-empty `json_metadata`, in which case it is `'active'`. `custom_json` is the other special -case, active only when it declares `required_auths`. `src/utils/hiveOperationAuthority.test.ts` -pins every branch. - -That resolver currently serves the hive-uri path only (`src/providers/hive/hive.ts:750` and -`src/hooks/useLinkProcessor.tsx:648`); mutation wrappers still pass authority literally. So if -you write an `account_update2` wrapper, decide from the payload rather than copying `'active'` -out of the SDK map. `useBroadcastMutation` never consults that map either: its `authority` -parameter defaults to `'posting'`, so always pass the right value explicitly. +`account_update2` to `'active'` flatly, which is right for the common cases but wrong at both +ends, so do not copy it for this operation. + +`account_update2` is the one operation whose authority depends on its payload: + +| Payload sets | Authority Hive requires | +|---|---| +| only `posting_json_metadata` (profile edit, pinned post) | `'posting'` | +| `active`, `posting`, `memo_key`, or a non-empty `json_metadata` | `'active'` | +| `owner` | `'owner'` | + +The owner row is not optional. Hive's own test matrix has an active-signed or posting-signed +owner update failing outright (hive issue 520), so an `'active'` broadcast of an owner change +is rejected on chain. + +`src/utils/hiveOperationAuthority.ts:37` implements the first two rows. It does NOT implement +the third: it is typed `(operation: Operation) => 'posting' | 'active'`, so its `owner` branch +returns `'active'`, and `hiveOperationAuthority.test.ts` has no owner case. That resolver +serves the hive-uri path (`src/providers/hive/hive.ts:750` and +`src/hooks/useLinkProcessor.tsx:648`), where the operations arrive from an external link, so a +deep link that changes `owner` is currently signed with the wrong key. Mobile has no owner +signing path at all: `mobilePlatformAdapter.ts` decrypts only the posting plus active keys and +does not implement `getOwnerKey`, so the SDK's own `case 'owner'` throws "Owner key not +supported by adapter". Treat an owner change as unsupported and reject it rather than routing +it to `'active'`. + +`custom_json` is the other payload-dependent case, active only when it declares +`required_auths`. + +None of this is reachable from one wrapper. `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 +// posting: profile edit, pinned post, anything touching only posting_json_metadata +export const useUpdateProfileMetadataMutation = () => { /* ..., 'posting' */ }; +// active: json_metadata or a key or authority change +export const useUpdateAccountKeysMutation = () => { /* ..., 'active' */ }; +``` + +`useBroadcastMutation` never consults `OPERATION_AUTHORITY_MAP` either: its `authority` +parameter just defaults to `'posting'`, so always pass the right value explicitly. Prefer an SDK `buildOp` helper (`buildTransferOp`, `buildVoteOp`) over a hand written op tuple. From e4744b1ca88194262e48cfab491f0b6173238ad4 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 12:31:59 +0000 Subject: [PATCH 05/11] fix(skills): owner authority applies to account_update too The table covered account_update2 only, while listing account_update as flatly active. Hive issue 520 states its cases 1 to 15 are the same for account_update_operation as for account_update2_operation, which includes the owner cases, so a v1 account_update that sets owner also requires owner authority. The table is now keyed by operation with the owner row spanning both versions. src/utils/hiveOperationAuthority.ts handles v1 worse than v2: it has no account_update branch at all, so v1 falls through to active unconditionally. Its doc comment asserts that is correct. The skill now records that gap. Tracked for the code in #3535. --- .claude/skills/add-mutation/SKILL.md | 47 +++++++++++++++------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/.claude/skills/add-mutation/SKILL.md b/.claude/skills/add-mutation/SKILL.md index dc5dd16a3d..82bcca59cc 100644 --- a/.claude/skills/add-mutation/SKILL.md +++ b/.claude/skills/add-mutation/SKILL.md @@ -89,27 +89,32 @@ Pass authority as a plain lowercase string. The parameter is typed `AuthorityLev - `'posting'`: vote, comment, reblog, follow, ignore, community roles - `'active'`: transfer, delegate, power up/down, savings, limit orders, proposal vote, - witness proxy, account_update - -The SDK's exported `OPERATION_AUTHORITY_MAP` is the reference list. It maps -`account_update2` to `'active'` flatly, which is right for the common cases but wrong at both -ends, so do not copy it for this operation. - -`account_update2` is the one operation whose authority depends on its payload: - -| Payload sets | Authority Hive requires | -|---|---| -| only `posting_json_metadata` (profile edit, pinned post) | `'posting'` | -| `active`, `posting`, `memo_key`, or a non-empty `json_metadata` | `'active'` | -| `owner` | `'owner'` | - -The owner row is not optional. Hive's own test matrix has an active-signed or posting-signed -owner update failing outright (hive issue 520), so an `'active'` broadcast of an owner change -is rejected on chain. - -`src/utils/hiveOperationAuthority.ts:37` implements the first two rows. It does NOT implement -the third: it is typed `(operation: Operation) => 'posting' | 'active'`, so its `owner` branch -returns `'active'`, and `hiveOperationAuthority.test.ts` has no owner case. That resolver + 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. + +The two account update operations are the ones whose authority depends on the payload: + +| 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 is not optional. It covers BOTH versions. Hive's own test matrix has an +active-signed or posting-signed owner update failing outright. It also states that its cases 1 +to 15 are the same for `account_update_operation` as for `account_update2_operation` +(hive issue 520). So an `'active'` broadcast of an owner change is rejected on chain either +way. + +`src/utils/hiveOperationAuthority.ts:37` 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:750` and `src/hooks/useLinkProcessor.tsx:648`), where the operations arrive from an external link, so a deep link that changes `owner` is currently signed with the wrong key. Mobile has no owner From 3260c7eca52e1dcc0864f1c3e048f268ac6b5754 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 12:39:02 +0000 Subject: [PATCH 06/11] fix(skills): custom_json is payload dependent too The authority section called the two account update operations "the ones" whose authority depends on the payload, then named custom_json as another case two paragraphs later. It is the third, so the section says that. --- .claude/skills/add-mutation/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-mutation/SKILL.md b/.claude/skills/add-mutation/SKILL.md index 82bcca59cc..46d5896855 100644 --- a/.claude/skills/add-mutation/SKILL.md +++ b/.claude/skills/add-mutation/SKILL.md @@ -95,7 +95,8 @@ 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. -The two account update operations are the ones whose authority depends on the payload: +Both account update operations have payload-dependent authority (`custom_json` is the third +payload-dependent case, below): | Operation | Payload sets | Authority Hive requires | |---|---|---| @@ -123,7 +124,7 @@ does not implement `getOwnerKey`, so the SDK's own `case 'owner'` throws "Owner supported by adapter". Treat an owner change as unsupported and reject it rather than routing it to `'active'`. -`custom_json` is the other payload-dependent case, active only when it declares +`custom_json` is the third payload-dependent case, active only when it declares `required_auths`. None of this is reachable from one wrapper. `useBroadcastMutation` takes `authority` as its From 44501c24db28bc93d4855a74c6d65442b4a5343c Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 12:45:41 +0000 Subject: [PATCH 07/11] fix(skills): a mixed-authority custom_json is invalid, not active The skill said custom_json is active whenever it declares required_auths. Hive rejects a payload that populates both required_auths and required_posting_auths outright, so it never reaches the chain (hive issue 632, case 2.3). Exactly one list may be populated. Neither implementation enforces that. 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 a payload the chain then refuses. The skill records the rule plus that gap, then says to reject a mixed payload before broadcasting. --- .claude/skills/add-mutation/SKILL.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-mutation/SKILL.md b/.claude/skills/add-mutation/SKILL.md index 46d5896855..5797d97504 100644 --- a/.claude/skills/add-mutation/SKILL.md +++ b/.claude/skills/add-mutation/SKILL.md @@ -124,8 +124,16 @@ does not implement `getOwnerKey`, so the SDK's own `case 'owner'` throws "Owner supported by adapter". Treat an owner change as unsupported and reject it rather than routing it to `'active'`. -`custom_json` is the third payload-dependent case, active only when it declares -`required_auths`. +`custom_json` is the third payload-dependent case. Exactly one authority list may be +populated: `required_auths` alone means active, `required_posting_auths` alone means posting. +A payload carrying both is INVALID, not active. Hive rejects it outright, so the operation +never reaches the chain (hive issue 632, case 2.3). Reject a mixed payload before broadcasting +rather than picking an authority for it. + +Neither implementation enforces that 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 route a mixed +payload to an active signature that the chain then refuses. None of this is reachable from one wrapper. `useBroadcastMutation` takes `authority` as its sixth positional parameter, fixed when the hook is created, while `operations` is From c377a2b57e52d337799ac91b34b6dd5aab3c1ea6 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 13:36:13 +0000 Subject: [PATCH 08/11] chore(skills): drop the counts the files could not keep true Reviewers found the same defect class eight rounds running: a count taken from a bare grep, an off-by-one line citation, or an exclusive quantifier the file itself contradicted. Correcting them made it worse, because each fix added fresh specifics. One pass produced 41 findings, seven of them created by that pass. So this removes the specifics instead. 236 counts deleted, 139 exclusive quantifiers weakened, 17 line citations trimmed to the file where the line could not be confirmed. The dozen counts kept were each re-derived by listing and classifying every match. Each one changes what a reader does. Both files are net shorter. The procedures and traps are unchanged; only the statistics that decorated them are gone. --- .claude/skills/add-feature/SKILL.md | 77 ++++++++-------- .claude/skills/add-mutation/SKILL.md | 84 +++++++++-------- .claude/skills/add-query/SKILL.md | 77 ++++++++-------- .claude/skills/add-sheet/SKILL.md | 73 ++++++++------- .claude/skills/code-review/SKILL.md | 132 ++++++++++++--------------- .claude/skills/debug/SKILL.md | 107 +++++++++++----------- 6 files changed, 260 insertions(+), 290 deletions(-) diff --git a/.claude/skills/add-feature/SKILL.md b/.claude/skills/add-feature/SKILL.md index 8147836afb..02968661b0 100644 --- a/.claude/skills/add-feature/SKILL.md +++ b/.claude/skills/add-feature/SKILL.md @@ -6,31 +6,31 @@ argument-hint: [feature-name] # Add Feature -Ordered procedure for adding a screen. Redux, TanStack Query, `@ecency/sdk`, sheets and lint rules -are covered in CLAUDE.md and not repeated. Two steps are easy to miss and each one breaks something: -the safe-area root (Step 1) and the params contract (Step 4). +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. -New screens are functional. Only 12 of the 142 `.tsx` files under `src/screens/` are classes: 11 -legacy holdouts, plus `application/children/errorBoundary.tsx`, which React requires to be a class. -A dismissible overlay is a bottom sheet instead: `src/navigation/sheets.tsx`, see CLAUDE.md. +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. ```text src/screens// index.ts # local barrel, re-exported from src/screens/index.ts screen/Screen.tsx - screen/Styles.ts # or .styles.ts; 8 .tsx files inline EStyleSheet.create + screen/Styles.ts # or .styles.ts children/ hooks/ # optional ``` ## Step 1: screen rooted in SafeAreaView -Root must be `SafeAreaView` from `react-native-safe-area-context`: 40 files under `src/screens/` -import it from there. The one file that still takes `SafeAreaView` from `react-native` is -`src/screens/dappBrowser/screen/dappBrowser.tsx`, 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 -Email digests; the fix to that screen in PR #3531 was the single-line deletion of -`edges={['bottom']}`. +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']}`. Skeleton for `screen/Screen.tsx` (a template, not a quote of any one file): @@ -59,23 +59,23 @@ const MyFeatureScreen = () => { export default MyFeatureScreen; ``` -`useAppSelector(selectCurrentAccount)` reads the account (81 call sites); `useAuth()` from -`src/hooks` (45) when you only need `{ username, code }`. Reusable UI is exported from the -`src/components/index.tsx` barrel (`BasicHeader`, `MainButton`, `TextInput`, `UserAvatar`, `Icon`). +`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`). 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. A hex literal breaks dark mode. +`$iconColor`, `$primaryRed` destructive. Hex literals do not follow the theme. ## 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` pulls its screens from that barrel, so a screen -missing from it cannot be registered there. `src/screens/waves` shows the cost of skipping this: it -never reached the barrel, so `botomTabNavigator.tsx` has to reach it by path. +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 @@ -94,34 +94,32 @@ object ends `as const`, which is what makes the route names a literal union: export type _MissingRouteContracts = AssertNever>; ``` -A ROUTES entry with no `AppParamList` entry is a compile error: `TS2344: Type -'"MyNewFeatureScreen"' does not satisfy the constraint 'never'`, plus five cascading `TS2536: Type -'K' cannot be used to index type 'AppParamList'` from the mapped types above it. `yarn typecheck` -runs against an empty baseline, so this fails CI. Add: +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: ```ts [ROUTES.SCREENS.MY_FEATURE]: { username?: string } | undefined; ``` Append `| undefined` only if the screen renders with no params. Leaving it off makes params required -at every call site, which is what you want for a screen that cannot render empty (`WEB_BROWSER`, +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`). ## Step 5: register in the navigator -`src/navigation/stackNavigator.tsx` holds two. `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 the pre-auth -and full-screen routes (Login, Register, Welcome, PinCode, WebBrowser). Most new screens go in the -main one: +`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 ``` Put it in the `` block to slide -up, add `options={{ presentation: 'modal' }}` for a true modal. The 15 `as any` casts on existing -rows are legacy prop debt; a new screen needs none. +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. ## Step 6: navigating @@ -134,9 +132,8 @@ const navigation = useNavigation(); navigation.navigate(ROUTES.SCREENS.MY_FEATURE, { username }); ``` -Reading the params back is not settled house style. Most screens destructure a -`route` prop, usually typed `any`; only two call `useRoute`, one of them with a locally declared -`RouteProp` (`dappBrowser.tsx`). Prefer keying off Step 4 instead. No screen does this yet: +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'; @@ -146,13 +143,13 @@ const route = useRoute ``` Outside a component (deep links, redux actions) use the equally typed object form, -`RootNavigation.navigate({ name, params })` from `src/navigation/rootNavigation.tsx` (53 sites). +`RootNavigation.navigate({ name, params })` from `src/navigation/rootNavigation.tsx`. ## Step 7: strings -`src/config/locales/en-US.json` is the only catalog you edit; Crowdin owns the other 38. It is -**nested**: all 92 top-level keys are objects, none contains a dot. `src/utils/flattenMessages.ts` -joins the levels with dots at load, so a nested block is read with a dotted id. +`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" } } diff --git a/.claude/skills/add-mutation/SKILL.md b/.claude/skills/add-mutation/SKILL.md index 5797d97504..318c2250de 100644 --- a/.claude/skills/add-mutation/SKILL.md +++ b/.claude/skills/add-mutation/SKILL.md @@ -7,12 +7,13 @@ argument-hint: [operation-name] # Add Mutation Wrap an `@ecency/sdk` mutation hook in `src/providers/sdk/mutations/`. CLAUDE.md -("SDK Migration") covers the adapter; this file is only the procedure. +("SDK Migration") covers the adapter; this file is the procedure plus the Hive authority +rules a wrapper must respect. ## 1. Create the wrapper -`src/providers/sdk/mutations/useMutation.ts`. 40 of the 47 wrappers there are -exactly this shape, so copy it verbatim: +`src/providers/sdk/mutations/useMutation.ts`. Most wrappers there are exactly +this shape, so copy it verbatim: ```typescript import { useTransfer } from '@ecency/sdk'; @@ -24,21 +25,22 @@ export function useTransferMutation() { } ``` -- `'async'` is the broadcast mode: the last positional arg after `authContext`, so the - third arg in 40 wrappers but the fourth in the two community ones. Pass it unless the - hook has no such parameter. `useBroadcastMutation` takes it as +- `'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` (44 of 47 import it) returns +- `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, only the factory. -- Wrappers take no arguments. Three differ. `useSetCommunityRoleMutation(community)` plus + `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 - three because the SDK bakes the target plus both callbacks into the mutation options. -- Three of the 47 files are not broadcasts, so they skip `useMutationAuth`. - `useGenerateImageMutation` plus the three digest hooks in + 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)`. @@ -47,14 +49,13 @@ export function useTransferMutation() { ## 2. Export from the barrel -One line in `src/providers/sdk/mutations/index.ts`, under the matching domain comment. -Without it the hook is not importable: +One line in `src/providers/sdk/mutations/index.ts`, under the matching domain comment: ```typescript export { useTransferMutation } from './useTransferMutation'; ``` -## 3. Call it (from the barrel, never the file) +## 3. Call it (from the barrel) ```typescript import { useFollowMutation } from '../providers/sdk/mutations'; @@ -64,9 +65,9 @@ await followMutation.mutateAsync({ following: data.following }); ## No SDK hook for the operation? -There is no `packages/sdk` here. `@ecency/sdk` is an npm dependency (`^2.3.93`), so there +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. Seven positional args: +`useIgnoreUserMutation.ts` does. The positional args: ```typescript return useBroadcastMutation( @@ -83,7 +84,7 @@ return useBroadcastMutation( ``` Pass authority as a plain lowercase string. The parameter is typed `AuthorityLevel` from -`@ecency/sdk` (`'posting' | 'active' | 'owner' | 'memo'`); mobile wrappers only ever use +`@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. @@ -104,38 +105,34 @@ payload-dependent case, below): | `account_update2` | only `posting_json_metadata` (profile edit, pinned post) | `'posting'` | | both | anything else | `'active'` | -The owner row is not optional. It covers BOTH versions. Hive's own test matrix has an -active-signed or posting-signed owner update failing outright. It also states that its cases 1 -to 15 are the same for `account_update_operation` as for `account_update2_operation` -(hive issue 520). So an `'active'` broadcast of an owner change is rejected on chain either -way. +The owner row covers BOTH versions. -`src/utils/hiveOperationAuthority.ts:37` implements the posting row plus the active row for +`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:750` and -`src/hooks/useLinkProcessor.tsx:648`), where the operations arrive from an external link, so a -deep link that changes `owner` is currently signed with the wrong key. Mobile has no owner -signing path at all: `mobilePlatformAdapter.ts` decrypts only the posting plus active keys and -does not implement `getOwnerKey`, so the SDK's own `case 'owner'` throws "Owner key not -supported by adapter". Treat an owner change as unsupported and reject it rather than routing +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. Exactly one authority list may be -populated: `required_auths` alone means active, `required_posting_auths` alone means posting. -A payload carrying both is INVALID, not active. Hive rejects it outright, so the operation -never reaches the chain (hive issue 632, case 2.3). Reject a mixed payload before broadcasting -rather than picking an authority for it. +`custom_json` is the third payload-dependent case. `required_auths` alone means active, +`required_posting_auths` alone means posting. -Neither implementation enforces that today. `hiveOperationAuthority.ts` never reads +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 route a mixed -payload to an active signature that the chain then refuses. +as `required_auths` is non-empty, without checking the other list. Both therefore sign with one +key. -None of this is reachable from one wrapper. `useBroadcastMutation` takes `authority` as its +`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, @@ -144,12 +141,13 @@ write two hooks with fixed authorities and pick at the call site: ```typescript // posting: profile edit, pinned post, anything touching only posting_json_metadata export const useUpdateProfileMetadataMutation = () => { /* ..., 'posting' */ }; -// active: json_metadata or a key or authority change +// active: json_metadata or a key or authority change other than owner (an owner +// change is unsupported, reject it) export const useUpdateAccountKeysMutation = () => { /* ..., 'active' */ }; ``` -`useBroadcastMutation` never consults `OPERATION_AUTHORITY_MAP` either: its `authority` -parameter just defaults to `'posting'`, so always pass the right value explicitly. +`useBroadcastMutation`'s `authority` parameter defaults to `'posting'`, so pass the right +value explicitly. Prefer an SDK `buildOp` helper (`buildTransferOp`, `buildVoteOp`) over a hand written op tuple. @@ -157,7 +155,7 @@ written op tuple. ## Gotchas 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 (60s temp key). + 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 5034b804da..2125f45986 100644 --- a/.claude/skills/add-query/SKILL.md +++ b/.claude/skills/add-query/SKILL.md @@ -10,10 +10,9 @@ Read `CLAUDE.md` first (State Management, SDK Migration). Writes are a separate ## Rule: the SDK owns the fetch -`@ecency/sdk` 2.3.93 exports **165** `get*QueryOptions` helpers. 25 of the 32 non-test files under -`src/providers/queries/` import from `@ecency/sdk`; only **2** `queryFn:` remain in that whole -directory. Search the SDK for your own domain first. Write a `queryFn` only when that search comes -back empty: +`@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: ```bash D=node_modules/@ecency/sdk/dist/browser/index.d.ts @@ -22,9 +21,8 @@ grep -o "get[A-Za-z]*QueryOptions" "$D" | sort -u | grep -i draft # swap in yo grep -n "declare function getPostQueryOptions" "$D" ``` -Drop the trailing `| grep -i draft` to list all 165. The last grep gives the real argument order. -Never guess it. `getPostQueryOptions(author, permlink?, observer?, num?)` takes the observer third. -13 of its 14 call sites pass one. +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 @@ -39,8 +37,8 @@ const { data: account } = useQuery(getAccountFullQueryOptions(author)); ## 2. App hook that adds mobile-only options -The dominant shape: spread the SDK options, then override. 47 spread sites across `src/`. -Verbatim, `src/providers/queries/leaderboardQueries/leaderboardQueries.ts`: +The dominant shape: spread the SDK options, then override. Verbatim, +`src/providers/queries/leaderboardQueries/leaderboardQueries.ts`: ```typescript import { useQuery } from '@tanstack/react-query'; @@ -60,12 +58,12 @@ export const useGetLeaderboardQuery = (duration: 'day' | 'week' | 'month') => { ``` Usual overrides: `enabled`, `select`, `staleTime`, `gcTime`, `initialData`. Keep the SDK's -`queryKey` plus `queryFn` so the cache entry stays shared with every other surface. +`queryKey` plus `queryFn` so the cache entry stays shared with other surfaces. ## 3. Private-API queries need the auth pair -Ecency backend queries take `username` plus an access token. Use `useAuth()` (12 query files do), -never re-derive it. From `src/providers/queries/newsletterQueries.ts`: +Ecency backend queries take `username` plus an access token. Use `useAuth()` rather than +re-deriving it. From `src/providers/queries/newsletterQueries.ts`: ```typescript import { useAuth } from '../../hooks'; @@ -79,8 +77,8 @@ export const useDigestSubscriptionsQuery = () => { ## 4. Infinite queries SDK `get*InfiniteQueryOptions` already carry `initialPageParam` plus `getNextPageParam`. The repo -hand-rolls those two exactly once out of 18 `useInfiniteQuery` calls. Flatten in the hook, do not -re-key. From `src/providers/queries/draftQueries.ts` (comments stripped): +rarely hand-rolls those two. Flatten in the hook, do not re-key. From +`src/providers/queries/draftQueries.ts` (comments stripped): ```typescript const { username, code } = useAuth(); @@ -105,14 +103,13 @@ return { ...infiniteQuery, data, pagesLoaded: infiniteQuery.data?.pages?.length `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. 10 files import that default (`import QUERIES from '/queryKeys'`, + 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. ## 6. Hand-rolled query (last resort) -Only when the SDK has nothing. Verbatim, one of the two survivors, -`src/providers/queries/settingsQueries.ts`: +Only when the SDK has nothing. Verbatim, `src/providers/queries/settingsQueries.ts`: ```typescript export const useGetServersQuery = () => { @@ -129,34 +126,34 @@ export const useGetServersQuery = () => { ## 7. Export -`src/providers/queries/index.ts` uses `export * from './Queries'` (16 of them). Its only -named re-export is `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, for example `export { postQueries, wavesQueries, pollQueries };`. +`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]`. Only `core`, `get-account-full` plus - `points` persist wholesale. `posts`, `accounts`, `notifications` persist part of their subtypes: - `accounts` returns false unless the subtype is `bookmarks` or `favorites`, `posts` drops `entry`, - `notifications` drops `announcements`. Everything else is dropped, so a new namespace or subtype - is not persisted until you add its case. Read the switch before assuming a new key persists. - Infinite lists persist only while a single page is loaded. -2. **Guard with `enabled`** whenever a param can be undefined: 27 uses under `providers/queries` - (`grep -rnE "^[[:space:]]*enabled[,:]" src/providers/queries | wc -l`). An `undefined` anywhere - in a query key also blocks persistence. -3. Returning `undefined` from `getNextPageParam` stops pagination. `null` stops it too on the - installed TanStack Query 5.83.0, whose `hasNextPage` tests `!= null`, but the repo's one - hand-rolled case returns `undefined`. + 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. -5. **Non-React code**: import `getQueryClient` from the app barrel `providers/queries` (19 sites) - rather than the SDK (5): `await queryClient.fetchQuery(getAccountsQueryOptions([username]))`. -6. `src/providers/queries/sdk-config.ts` runs once from `initQueryClient()` and configures + 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 never requires touching it. + query rarely requires touching it. -Prettier width is 100 (`.prettierrc`). Finish with `yarn lint` plus `yarn typecheck`; the baseline -in `tsc-baseline.json` is empty, so any type error fails CI. +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 84ef777023..e4e98065dd 100644 --- a/.claude/skills/add-sheet/SKILL.md +++ b/.claude/skills/add-sheet/SKILL.md @@ -7,7 +7,7 @@ argument-hint: [sheet-name] # Add Sheet Registry, show call and mount lifecycle: CLAUDE.md "Sheets (Bottom Sheets)". This file adds -the procedure plus the result convention every sheet here follows. +the procedure plus the result convention new sheets here should follow. ## Resolve with an object, gate on a named field @@ -23,11 +23,10 @@ index.js:53, `useRef(payload)` at 87, `payloadRef.current = payload` at 139). Th `SheetManager.show` payload goes somewhere else entirely, to the registered component as a prop from the provider (``, `dist/src/provider.js:160`). -Nothing in `src/` forwards that prop down into ``: `grep -rn "payload=" src/` returns -zero hits. The only spreads onto an `` are narrow literals such as -`{...({ hideUnderlay: true } as any)}`. So `payloadRef.current` is `undefined` for every sheet -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). +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: @@ -42,11 +41,11 @@ 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:101` is the counter-example: it cancels with +`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 the sheet that breaks first if anyone gives it -a `payload` prop. +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 @@ -54,10 +53,9 @@ reason is not. ## Step 1: Component -`src/components//.tsx` is the usual path: 16 files type themselves with -`SheetProps<'...'>` and 12 of those sit at that path. Nine more sheets use the enum form -`SheetProps`, which is equally accepted. Trimmed from -`src/components/modNotesSheet/modNotesSheet.tsx`: +`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, { useCallback, useEffect, useRef, useState } from 'react'; @@ -66,7 +64,7 @@ import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-shee import EStyleSheet from 'react-native-extended-stylesheet'; import { MainButton } from '../mainButton'; -// Matches the SheetNames value. 10 files keep this so `hide` has an id even when the +// 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'; @@ -88,10 +86,11 @@ const MySheet: React.FC> = ({ sheetId, payload }) => { setValue(''); }, []); - // onBeforeShow is the authoritative reset: it fires on every fresh presentation. - // This effect covers the one case it misses, 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. + // 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]); @@ -122,15 +121,14 @@ export default MySheet; ``` Colors come from EStyleSheet theme variables in `src/themes/` (`$primaryBackgroundColor`, -`$primaryBlack`, `$primaryDarkGray`, `$iconColor`), never a hex. For a color a prop needs as a -plain string rather than a style, resolve it with `EStyleSheet.value('$primaryDarkGray')`, as +`$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 2: Folder index -`src/components//index.ts`. 11 sheet folders re-export their sheet with the first -line; the 5 that also publish a result type (`modNotesSheet`, `communityManageSheet`, -`communityRoleEditSheet`, `searchFiltersSheet`, `newsletterDigestSheet`) add the second: +`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 { default as MySheet } from './'; @@ -142,18 +140,18 @@ has no `index.ts`. ## Step 3: Components barrel -`src/components/index.tsx` is an import list plus ONE `export { ... }` block at line 164. Add +`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 7 of the 29 registrations are. +in `sheets.tsx` by path, as some registrations are. ## Step 4: Register in `src/navigation/sheets.tsx` 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]`** (29 literal keys, 0 computed): string enum member -types are nominal, so with computed keys `keyof Sheets` accepts only enum members and every -`SheetProps<'my_sheet'>` fails with TS2344. +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. ```typescript declare module 'react-native-actions-sheet' { @@ -189,7 +187,7 @@ Real callers: `src/components/postOptionsModal/container/postOptionsModal.tsx:74 ## Step 6: i18n strings Edit `src/config/locales/en-US.json` only; Crowdin owns the other locales. The file is nested -objects (all 92 top-level keys, zero dotted ones) while `formatMessage` ids stay dotted: +objects, not dotted keys, while `formatMessage` ids stay dotted: ```json "my_sheet": { "title": "Sheet Title", "confirm": "Confirm", "cancel": "Cancel" }, @@ -197,15 +195,16 @@ objects (all 92 top-level keys, zero dotted ones) while `formatMessage` ids stay ## Lifecycle -- **Sheets mount on show and unmount on hide**: `SheetProvider` renders `!visible ? null : ` - (`dist/src/provider.js:156`). `useState` initials are fresh every open, so nothing stale needs - clearing on mount. Every unmount cleanup runs on every close. Source comments claiming sheets - stay mounted are stale. +- **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 freezes at show time**: the provider stores it in state at show, so a callback - passed inside a payload keeps the closure it had when the sheet opened. Route anything that - changes through a ref, as `src/components/quickPostModal/quickPostModalContent.tsx:565-575` - does. +- **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 88c326fba3..54776aa623 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -1,29 +1,21 @@ --- name: code-review -description: Review a vision-mobile React Native change (diff, branch, PR or file) against this repo's own shipped-bug traps: action sheet return values, setNativeProps caret, safe-area edges, editor teardown order, notification routing copies, SDK mutation wrappers, EStyleSheet theming. +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] --- # Code Review Architecture, commands, TypeScript and ESLint rules live in CLAUDE.md. This file holds -only traps that have already shipped bugs here. Confirm each finding against the code -on disk before reporting it. +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.** This is a repo convention with a - reason, not something the library enforces. `react-native-actions-sheet` 0.9.7 - publishes `data || payloadRef.current || data` on close (`dist/src/index.js:408`) - where `payloadRef` tracks ``'s own `payload` prop - (`dist/src/index.js:87` and `:139`). The provider hands the `SheetManager.show` - payload to the registered component (`dist/src/provider.js:160`) but no sheet in - `src/` forwards it on to ``, so `payloadRef.current` is `undefined` - today and a falsy resolve survives: `if (!result) return false;` at - `src/providers/sdk/mobilePlatformAdapter.ts:317` reads a dismissal correctly. One - added `payload={payload}` on an `` would silently turn every falsy - cancel into a truthy confirm, which is why sheets resolve `{ cancelled: true }` or - `{ field: value }` instead. Six sheets document the contract, e.g. + reason, not something the library enforces: a dismissal can resolve something truthy, + so `if (result)` may read a cancel as a confirm. 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 @@ -32,8 +24,8 @@ on disk before reporting it. }); if (result && typeof result === 'object' && result.filters) { ... } ``` -- [ ] **Sheets unmount on hide**, so mount-time resets are enough and every cleanup - runs on every close (CLAUDE.md, Sheets). Reject "state persists between invocations". +- [ ] **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. @@ -41,106 +33,96 @@ on disk before reporting it. 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 string - literals, never `[SheetNames.X]`. + (`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's - `updateExtraData` 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. 20 call - sites, of which 3 pass `selection`: +- [ ] **`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 every descendant effect - cleanup, so the screen calls `flushPendingEditorWork()` then `_saveDraftToDB()` + `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`), never a local cleanup. + (`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 the screen's job: a screen rendering - `BasicHeader` wraps it in its own `SafeAreaView` - (`src/components/basicHeader/view/basicHeaderStyles.ts:13`), while child components - and `Modal` bodies inherit the screen's. Modals use - `Platform.select({ ios: [], default: ['top'] })`. + 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 -Three separate copies whose type strings do NOT match. A new type must be added to -every copy it should reach. +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` handles 15 types - (`vote`, `unvote`, `mention`, `follow`, `unfollow`, `ignore`, `reblog`, - `scheduled_published`, `favorite`, `bookmark`, `reply`, `transfer`, `inactive`, - `spin`, `hiveuri`); its `default` does nothing. + `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 8 types - (`mention`, `reply`, `transfer`, `delegations`, `scheduled_published`, `payouts`, - `account_update`, `weekly_earnings`). Each one also needs a case in the title/body + `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 five - (`reply`, `mention`, `transfer`, `delegations`, `scheduled_published`); anything else - shows nothing. Its own `_onPress` (`:127`) routes `transfer` and `delegations` to the - wallet, everything else to a post. + `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`, the list and websocket -paths match `favorites`/`payouts`. +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 two imports plus a four-line function: - `useMutationAuth()` from `src/providers/sdk/mutations/common.ts` then the SDK hook - (44 of the 47 wrapper files). No key decryption, no HiveSigner/HiveAuth branching in - one; the adapter owns that. The other three files are not broadcasts and are the - documented exceptions, so do not report them: `useGenerateImageMutation.ts` plus the - three digest hooks in `useNewsletterDigestMutations.ts` bind the HiveSigner `code` - from `useAuth()`, while `useClaimPointsMutation.ts` derives a REST access token by - decrypting `currentAccount.local.accessToken` with `getDigitPinCode(pin)`. A new - wrapper that reaches for keys without a non-broadcast reason is still a finding. -- [ ] Optional query params need `enabled: !!param` (38 call sites). +- [ ] 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` (10 importers); SDK-owned data uses `QueryKeys` - from `@ecency/sdk`. + `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` (262 files) 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`: `'#357ce6'` is - `$primaryBlue` yet is written out 5 times across 4 files. Deliberately + `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`; 27 of the 262 files already hold a - hex literal, so only flag ones on a surface that should follow the theme. `$white` is - `#1e2835` in the dark theme, `$pureWhite` is the one that stays white. + `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`, only a +- [ ] Redux reads use `useAppSelector` (`src/hooks/index.ts:6`, a `TypedUseSelectorHook` alias) plus a memoized selector from - `src/redux/selectors` (264 calls). The finding is an inline lambda picking state - apart, not the hook name: three hooks call `react-redux`'s `useSelector` directly and - still pass a memoized selector (`src/hooks/useImageReveal.ts:14`, - `src/hooks/useContentLanguageGate.ts:80`, - `src/hooks/useTransferMutations.ts:34`), which types identically. Handlers are - `_`-prefixed (496 `const _handle*`/`const _on*` against 111 unprefixed). + `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` (empty baseline, any error fails CI) -and `yarn test:ci`. +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 a9869d8047..34134d118a 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -11,7 +11,7 @@ adds the per-area entry points plus the traps. Verify against the code before ac ## 1. Auth / broadcast -`authType` is a **string**, never a number (`src/constants/authType.ts`): +`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: @@ -19,22 +19,25 @@ adds the per-area entry points plus the traps. Verify against the code before ac |---|---| | `'steemConnect'` | `'hivesigner'` | | `'hiveAuth'` | `'hiveauth'` | -| the five key types above | `'key'` | +| the key types above | `'key'` | | anything else | `'key'` plus an `[AuthMapper] Unknown authType` warning | -(CLAUDE.md still says `AUTH_TYPE 1/2/5/7`. Those numbers appear nowhere in `src/`.) +(CLAUDE.md still describes `AUTH_TYPE` as numbers; the code uses the strings above.) -Routing is `src/providers/sdk/mobilePlatformAdapter.ts`. `getLoginType(username, authority)` -overrides the map twice: a key user doing an `active` op with `local.activeKey` signs directly; -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`. +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`. Posting covers only `vote`, `comment`, `comment_options`, -`custom_json`, `delete_comment`, `claim_reward_balance`; `custom_json` with `required_auths` plus -`account_update2` touching keys or `json_metadata` escalate. Everything else is active. - -- **Active key gone right after upgrade**: `setTempActiveKey` expires it after `60_000` ms while +`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. A transaction needs active if +any one of its operations does. + +- **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, ... })` @@ -47,11 +50,10 @@ Authority per operation: `resolveOperationAuthority` / `resolveTxRequiredAuthori 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 `error_description` plus `message` 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. + `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 @@ -63,15 +65,15 @@ composes SDK options (`getPortfolioQueryOptions`, `getPointsQueryOptions`, - Delegations are `getVestingDelegationsQueryOptions(username, limit)` (`delegateScreen.tsx`, `src/screens/assetDetails/children/delegationsModal.tsx`). The SDK also exports - `getHivePowerDelegatingsQueryOptions`, but mobile never uses it (0 hits in `src/`), so do not - reach for it by name. + `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 not `Error` instances, so `String(error)` collapses - them. See `src/components/upvotePopover/container/upvotePopover.tsx`. +- **`[object Object]`**: RPC rejections are often not `Error` instances, so `String(error)` + collapses them. See `src/components/upvotePopover/container/upvotePopover.tsx`. ## 3. Navigation @@ -79,15 +81,15 @@ composes SDK options (`getPortfolioQueryOptions`, `getPointsQueryOptions`, 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 36 `` entries plus 9 - `` entries for the pre-login and modal routes (`STACK.MAIN`, - `SCREENS.REGISTER`, `LOGIN`, `WELCOME`, `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. The remaining routes are the - 5 `` in `botomTabNavigator.tsx` and `` in - `drawerNavigator.tsx`. -- **Deep link dead**: `src/hooks/useLinkProcessor.tsx` exports only `handleLink`, which - dispatches to `_handleEcencyAuthTransferDeeplink`, `_handleEcencyLoginDeeplink`, + 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 @@ -96,34 +98,29 @@ is intentional), `appNavigator.tsx`, plus `rootNavigation.tsx` for non-React nav ## 4. Bottom sheets -Registry `src/navigation/sheets.tsx`: the `SheetNames` enum and the `registerSheet` calls are -one-to-one (29 each today). +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; 7 registered sheets are imported by direct + 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 no sheet state survives a close. What a +- **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. -- **Result is `undefined`**: a sheet resolves with what it passes to - `SheetManager.hide(sheetId, { payload: value })` (`src/components/authUpgradeSheet/`). A - backdrop dismiss resolves `undefined`, so a falsy result never means confirmed, but it does not - say why: `SignConfirmSheet` resolves `false` from its Cancel button as well as from its - `onClose`, 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: +- **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;` -- The library close path publishes `data || payloadRef.current || data`, where `payloadRef` is the - `payload` **prop of ``**, not the show payload the wrapper receives. No sheet here - forwards it (0 hits for `payload=` in `src/`), so the fallback is inert and a dismissal really - does resolve `undefined`. Forward `payload` into `` and a dismissal starts resolving - that truthy payload instead, which reads as confirmed. - A throw from a sheet render or cleanup is fatal: sheets sit outside the ErrorBoundary. ## 5. Theme -`react-native-extended-stylesheet` is built by the only `EStyleSheet.build` call in the repo, +`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 on every -theme toggle. Stylesheet values therefore re-resolve; only a value read outside a stylesheet stays +`[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'`. | Variable | Light | Dark | @@ -142,18 +139,18 @@ so switching to them fixes nothing. Bad dark mode colors usually mean a literal Config `src/providers/queries/sdk-config.ts` (`initSdkConfig`), client `src/providers/queries/index.ts`. -- **No fetch**: check `enabled`; an undefined username silently disables the query. +- **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 only warns on failure, so a wrong key looks like success. -- **RPC errors**: `ConfigManager.setHiveNodes(nodes)` runs once from the saved server plus - `getNodes()`, both filtered by `withoutBlockedServers` / `isBlockedServer` - (`src/constants/options/api.ts`); `hiveTxConfig.timeout` is 10000 ms. A blocked node is never - retried, so check the pool before blaming failover. + 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. ## 7. Build ```bash -bash patch-gradle.sh # required for RN 0.79.5, also runs on install +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 From 6b6974af8df7fdb16f49198e64ff2494756ee4c9 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 13:43:21 +0000 Subject: [PATCH 09/11] fix(skills): do not claim a mixed transaction resolves to active The debug guide said a transaction needs active authority whenever any one operation does. That describes what resolveTxRequiredAuthority computes, not what Hive requires of a batch holding both a posting-only operation and an active one. HF28 lifted the ban on mixing the two in a single transaction, so such a batch can now arrive from a deep link where it previously could not. Whether one active signature satisfies it is not settled here: the protocol source and the review disagree, so the skill records the resolver behaviour, marks the mixed case untested, then declines to assert an outcome either way. --- .claude/skills/debug/SKILL.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 34134d118a..89c250def0 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -34,8 +34,15 @@ Authority per operation: `resolveOperationAuthority` / `resolveTxRequiredAuthori `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. A transaction needs active if -any one of its operations does. +`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 unverified for a mixed one, meaning a batch holding both a +posting-only operation and an active operation. HF28 lifted the old ban on mixing the two in one +transaction, so such a batch can now arrive from a deep link where it previously could not, and +whether one active signature satisfies it is not settled here. Treat a mixed batch as untested +rather than assuming either outcome. - **Active key gone right after upgrade**: `setTempActiveKey` expires it on a timer while `getActiveKey` calls `clearTempActiveKey()` on read, so it is single use. From af6cd6d46a336f49fb99a0125f719079ae8105b8 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 13:51:04 +0000 Subject: [PATCH 10/11] fix(skills): mixed-authority batches are settled, not unverified The previous commit declined to say whether one active signature satisfies a transaction holding both posting-only and active operations. The verifier settles it. verify_authority checks the required posting authorities, calls clear_approved(), then checks the required active ones, so the two sets are satisfied independently and one signature does not cover both. Mobile signs with a single key, so a mixed batch is unsupported by this client, exactly like a mixed custom_json. The code-review sheet rule also stated the falsy-cancel hazard unconditionally. The library substitutes its own payload prop for a falsy result, but no sheet here passes that prop, so falsy results reach callers intact today. The convention still holds, for the two reasons now stated. --- .claude/skills/code-review/SKILL.md | 9 ++++++--- .claude/skills/debug/SKILL.md | 12 +++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 54776aa623..cd369eb685 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -12,9 +12,12 @@ code on disk before reporting it. ## Action sheets -- [ ] **Resolve an object, gate on a named field.** This is a repo convention with a - reason, not something the library enforces: a dismissal can resolve something truthy, - so `if (result)` may read a cancel as a confirm. Sheets resolve `{ cancelled: true }` +- [ ] **Resolve an object, gate on a named field.** A repo convention, not something the + library enforces today. The library substitutes its own `payload` prop for a falsy + result, which would turn a cancel into a confirm, but no sheet here passes that prop, so + falsy results currently reach callers intact. The convention holds because one added + `payload` would flip every falsy cancel silently. It also holds because a dismissal + 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`: diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 89c250def0..ca35aba244 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -38,11 +38,13 @@ is posting unless it sets a non-empty `json_metadata` or any of `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 unverified for a mixed one, meaning a batch holding both a -posting-only operation and an active operation. HF28 lifted the old ban on mixing the two in one -transaction, so such a batch can now arrive from a deep link where it previously could not, and -whether one active signature satisfies it is not settled here. Treat a mixed batch as untested -rather than assuming either outcome. +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. From be9dea45007e9ef26870b5a71eb61e76cc29b7fc Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 28 Aug 2026 13:58:36 +0000 Subject: [PATCH 11/11] fix(skills): name the two payloads apart in the sheet rule A reviewer read "no sheet here passes that prop" as contradicted by the example below it, which calls SheetManager.show with a payload. Those are different things, but the wording invited the misreading, so the rule now separates them. The show payload, SheetManager.show(name, { payload }), goes to the registered component. Every sheet uses it. ActionSheet's own payload prop is what feeds payloadRef. Nothing in src/ sets it: `payload=` has zero occurrences, while all nine spreads onto an ActionSheet are narrow literals such as { hideUnderlay: true }. The context value sheetPayload is read only by the router at index.js:1135, never in the resolve path. So data || payloadRef.current || data still collapses to data, which the rule already said. Only the explanation changed. --- .claude/skills/code-review/SKILL.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index cd369eb685..e55d26c0f9 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -13,11 +13,19 @@ 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. The library substitutes its own `payload` prop for a falsy - result, which would turn a cancel into a confirm, but no sheet here passes that prop, so - falsy results currently reach callers intact. The convention holds because one added - `payload` would flip every falsy cancel silently. It also holds because a dismissal - resolves `undefined`, which truthiness cannot tell apart from a deliberate `false`. Sheets resolve `{ cancelled: true }` + 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`: