Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "react-native-keyboard-controller",
"interface": {
"displayName": "React Native Keyboard Controller"
},
"plugins": [
{
"name": "react-native-keyboard-controller",
"source": {
"source": "npm",
"package": "react-native-keyboard-controller",
"version": "latest",
"registry": "https://registry.npmjs.org"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
36 changes: 36 additions & 0 deletions .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "react-native-keyboard-controller",
"version": "1.22.0",
"description": "Portable skills for building and migrating React Native keyboard experiences with React Native Keyboard Controller.",
"author": {
"name": "Kiryl Ziusko",
"email": "zyusko.kirik@gmail.com",
"url": "https://github.com/kirillzyusko"
},
"homepage": "https://kirillzyusko.github.io/react-native-keyboard-controller/",
"repository": "https://github.com/kirillzyusko/react-native-keyboard-controller",
"license": "MIT",
"keywords": [
"react-native",
"keyboard",
"agent-skills",
"codex",
"migration",
"chat"
],
"skills": "./skills/",
"interface": {
"displayName": "React Native Keyboard Controller",
"shortDescription": "Build reliable React Native keyboard experiences.",
"longDescription": "Choose the correct keyboard architecture, migrate competing keyboard solutions, build production chat layouts, modernize KeyboardToolbar, and avoid unnecessary keyboard-state renders.",
"developerName": "Kiryl Ziusko",
"category": "Developer Tools",
"capabilities": ["Read", "Write"],
"websiteURL": "https://kirillzyusko.github.io/react-native-keyboard-controller/",
"defaultPrompt": [
"Choose the right RNKC architecture for this screen.",
"Migrate this keyboard implementation safely to RNKC.",
"Build a production-ready RNKC chat keyboard layout."
]
}
}
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"react-native": "src/index",
"source": "src/index",
"files": [
".codex-plugin",
"skills",
"src",
"lib",
"android",
Expand Down Expand Up @@ -43,6 +45,8 @@
"lint-clang": "find ios/ -iname *.h -o -iname *.m -o -iname *.mm | grep -v -e Pods -e build | xargs clang-format -i -n --Werror",
"prepare": "bob build > /dev/null 2>&1",
"release": "release-it",
"plugin:check-version": "node scripts/sync-plugin-version.mjs --check",
"plugin:sync-version": "node scripts/sync-plugin-version.mjs",
"example": "yarn --cwd example",
"pods": "cd example && pod-install --quiet",
"bootstrap": "yarn example && yarn && yarn pods"
Expand Down Expand Up @@ -73,7 +77,10 @@
"ios",
"android"
],
"repository": "https://github.com/kirillzyusko/react-native-keyboard-controller",
"repository": {
"type": "git",
"url": "git+https://github.com/kirillzyusko/react-native-keyboard-controller.git"
},
"author": "Kiryl Ziusko <zyusko.kirik@gmail.com> (https://github.com/kirillzyusko)",
"license": "MIT",
"bugs": {
Expand Down Expand Up @@ -147,6 +154,9 @@
]
},
"release-it": {
"hooks": {
"after:bump": "yarn plugin:sync-version"
},
"git": {
"commitMessage": "chore: release ${version}",
"tagName": "v${version}"
Expand Down
24 changes: 24 additions & 0 deletions scripts/sync-plugin-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { readFile, writeFile } from "node:fs/promises";

const checkOnly = process.argv.includes("--check");
const packageUrl = new URL("../package.json", import.meta.url);
const pluginUrl = new URL("../.codex-plugin/plugin.json", import.meta.url);

const packageJson = JSON.parse(await readFile(packageUrl, "utf8"));
const pluginJson = JSON.parse(await readFile(pluginUrl, "utf8"));

if (pluginJson.version === packageJson.version) {
console.log(`Plugin version matches package version ${packageJson.version}.`);
process.exit(0);
}

if (checkOnly) {
console.error(
`Plugin version ${pluginJson.version} does not match package version ${packageJson.version}.`,
);
process.exit(1);
}

pluginJson.version = packageJson.version;
await writeFile(pluginUrl, `${JSON.stringify(pluginJson, null, 2)}\n`);
console.log(`Updated plugin version to ${packageJson.version}.`);
88 changes: 88 additions & 0 deletions skills/build-rnkc-chat-screen/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
name: build-rnkc-chat-screen
description: Build, migrate, or review production React Native chat keyboard layouts with React Native Keyboard Controller. Use when implementing KeyboardChatScrollView, KeyboardStickyView composers, KeyboardGestureArea interactive dismissal, FlatList, FlashList, LegendList or custom virtualized-list adapters, inverted chats, safe-area or tab offsets, growing multiline inputs, emoji or bottom-sheet transitions, freeze behavior, keyboardLiftBehavior, jump-to-latest UI, AI streaming blankSpace, keyboard translucency, or chat keyboard troubleshooting and performance.
license: MIT
metadata:
author: kirillzyusko
source: react-native-keyboard-controller
---

# Build an RNKC Chat Screen

Treat chat as a coordinated scroll, composer, gesture, and keyboard system. Do not assemble it from a general-purpose avoiding view plus ad hoc event listeners.

## Inspect the product and existing screen

1. Identify the message list implementation and its ref contract.
2. Determine whether the list is inverted and how it maintains the latest-message position.
3. Locate the composer, safe-area ownership, bottom tabs, fixed margins, and multiline height behavior.
4. Define interactive dismissal behavior on iOS and Android.
5. Choose how messages should lift when the keyboard opens.
6. Identify emoji pickers, attachment sheets, voice panels, or other keyboard replacements.
7. Identify AI streaming or anchor-to-top behavior.
8. Record Reanimated, React Native, and architecture versions before applying troubleshooting flags.

## Read the relevant references

- Start with [references/layout-recipes.md](references/layout-recipes.md) for the base component tree, offsets, and lift behavior.
- Read [references/virtualized-lists.md](references/virtualized-lists.md) for `FlatList`, FlashList, LegendList, inversion, stable wrappers, and refs.
- Read [references/advanced-behaviors.md](references/advanced-behaviors.md) for multiline composers, custom panels, AI streaming, jump-to-latest, and visual keyboard treatment.
- Read [references/troubleshooting-and-validation.md](references/troubleshooting-and-validation.md) before applying feature flags, runtime workarounds, or declaring completion.

## Establish one owner per layer

Use this default architecture:

```text
KeyboardGestureArea owns interactive keyboard gestures and optional input offset
KeyboardChatScrollView owns message scroll range and keyboard-driven repositioning
KeyboardStickyView owns composer translation
TextInput owns focus and text entry
```

The list may render through `KeyboardChatScrollView` as a custom scroll component, but it remains the virtualization owner. `KeyboardChatScrollView` owns keyboard-related inset and position changes.

## Choose product semantics explicitly

Select `keyboardLiftBehavior` from product behavior, not personal preference:

- `always`: keep bottom content visible regardless of current position.
- `whenAtEnd`: lift only when latest content is visible.
- `persistent`: lift on open and keep the resulting position after close.
- `never`: allow the keyboard to overlap without moving messages.

Choose and document the reason. Preserve an existing chat's scroll semantics during migration.

## Add complexity only when required

- Add `KeyboardGestureArea` for interactive dismissal or associated input offset.
- Add `extraContentPadding` only when external content such as a multiline composer changes height.
- Add `freeze` only for transitions where keyboard-driven layout must pause.
- Add `blankSpace` only for a minimum scroll-space floor such as AI response streaming.
- Add `onEndVisible` for jump-to-latest or UI-thread end-state effects.
- Add `onContentInsetChange` when a list needs RNKC's effective Android inset for its own calculations.
- Add `applyWorkaroundForContentInsetHitTestBug` only when the specific upstream iOS issue is reproduced.

## Reject common chat mistakes

- Do not wrap the chat list in `KeyboardAvoidingView` or `KeyboardAwareScrollView` as the primary keyboard owner.
- Do not apply keyboard height as React state padding while `KeyboardChatScrollView` also manages insets.
- Do not let iOS automatic content inset adjustment compete with the chat scroll component.
- Do not forget to propagate `inverted` to both the list and `KeyboardChatScrollView`.
- Do not create a new `renderScrollComponent` function every render.
- Do not call the list's `scrollToEnd` when its calculation ignores RNKC's active inset; use the underlying chat scroll ref when affected.
- Do not enable Reanimated flags or Objective-C runtime workarounds without matching the documented version and symptom.

## Implement in layers

1. Make the simplest non-virtualized or existing-list chat work with a sticky composer.
2. Add interactive dismissal and correct ID wiring.
3. Add safe-area and fixed-element offsets.
4. Add the virtualized-list adapter and ref ownership.
5. Add multiline padding, custom-panel freeze, or AI blank space as required.
6. Add visual effects after layout behavior is stable.
7. Test iOS and Android throughout; do not defer all validation until the complete composition exists.

## Deliver an explainable result

When advising, return the component tree, list adapter, offset formula, lift behavior, advanced props, platform caveats, and test plan. When implementing, preserve application-specific message state, rendering, send logic, styling, and navigation unless they block the keyboard architecture.
4 changes: 4 additions & 0 deletions skills/build-rnkc-chat-screen/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "Build RNKC Chat Screen"
short_description: "Build production-ready chat keyboard layouts"
default_prompt: "Use $build-rnkc-chat-screen to build or refactor this chat screen around KeyboardChatScrollView and its supporting RNKC components."
120 changes: 120 additions & 0 deletions skills/build-rnkc-chat-screen/references/advanced-behaviors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Advanced Chat Behaviors

## Growing multiline composer

`KeyboardStickyView` moves the composer, but the message scroll view cannot infer how much the composer grows. Pass a Reanimated shared value through `extraContentPadding`.

Use a delta above the composer's baseline height when the list already reserves the baseline separately:

```tsx
const BASE_COMPOSER_HEIGHT = 42;
const extraContentPadding = useSharedValue(0);

const onComposerLayout = useCallback(
(event: LayoutChangeEvent) => {
const height = event.nativeEvent.layout.height;

extraContentPadding.value = withTiming(
Math.max(height - BASE_COMPOSER_HEIGHT, 0),
{ duration: 250 },
);
},
[extraContentPadding],
);

<KeyboardChatScrollView extraContentPadding={extraContentPadding} />;
<KeyboardStickyView>
<TextInput multiline onLayout={onComposerLayout} />
</KeyboardStickyView>;
```

If the list reserves no separate baseline, pass the full external composer height. State which convention the layout uses; mixing a full height with separately reserved baseline padding causes double space.

## Emoji picker, attachment sheet, or custom input panel

Use `freeze` to pause keyboard-driven padding, content offset, and scroll changes while switching away from the keyboard:

```tsx
const freeze = useSharedValue(false);

const openEmojiPicker = async () => {
freeze.value = true;
await KeyboardController.dismiss({ keepFocus: true });
showEmojiPicker.value = true;
};

const returnToKeyboard = () => {
showEmojiPicker.value = false;
freeze.value = false;
inputRef.current?.focus();
};

<KeyboardChatScrollView freeze={freeze} />;
```

The exact order depends on whether the custom panel occupies keyboard space, whether focus should remain, and how it animates. Freeze before the keyboard starts changing the layout. Unfreeze only when the next owner is ready.

Use a shared value when the transition originates on the UI thread or must be synchronous. A boolean is adequate for ordinary React-driven transitions.

## AI response streaming and `blankSpace`

`blankSpace` is a minimum inset floor:

```text
effective bottom padding = max(blankSpace, keyboard padding + extraContentPadding)
```

Use it to leave room below a newly sent user message while an AI response streams. Calculate the required space from the list's real measurements:

```tsx
blankSpace.value = Math.max(0, viewportHeight - contentHeightBelowAnchor);
```

As the response grows, decrease the blank space. Reset it when the content naturally fills the desired viewport or the anchoring interaction ends.

The current RNKC implementation clamps blank space to one scroll-view viewport. One viewport is enough to move short content without allowing a fully blank screen beyond that range. Do not design an adapter that requires arbitrary multi-viewport blank space without verifying the installed implementation.

On iOS with affected React Native versions, content-inset space may not receive touch or scroll input. Reproduce the issue before enabling `applyWorkaroundForContentInsetHitTestBug` because the workaround uses runtime swizzling.

## Jump to latest

Use `onEndVisible` to show or hide a jump button:

```tsx
const [showJump, setShowJump] = useState(false);

<KeyboardChatScrollView onEndVisible={(visible) => setShowJump(!visible)} />;
```

For UI-thread animation, pass a worklet and update a shared value rather than routing every end-state transition through React state.

Remember that logical end is bottom for non-inverted content and top for inverted content.

## Effective inset reporting

Use `onContentInsetChange` when a virtualized list maintains its own scroll target and needs RNKC's dynamic inset. The callback can fire on animation frames. Avoid React state if the value only drives UI-thread calculation.

This is especially relevant on Android because RNKC simulates content inset and the native `onScroll` payload does not report it as an iOS `contentInset`.

## Solid or animated keyboard background

Use `KeyboardEffects` behind the chat to change what is visible through the iOS keyboard:

```tsx
<KeyboardEffects>
<View style={{ flex: 1, backgroundColor: chatBackground }} />
</KeyboardEffects>
```

An opaque view creates a solid app-matching background. A gradient, Skia canvas, or animation can create richer effects. The keyboard translucency behavior is iOS-specific; Android's keyboard is opaque.

Use `KeyboardBackgroundView` instead when app UI should visually match the system keyboard surface rather than replace what appears behind the keyboard.

## Interactive gesture offset

`KeyboardGestureArea.offset` and `KeyboardChatScrollView.offset` solve different problems:

- Gesture-area offset associates keyboard gesture behavior with the composer or accessory height and matching input IDs.
- Chat-scroll offset describes the fixed distance between the scroll view and physical screen bottom.

Do not copy the same number into both without tracing the layout.
Loading
Loading