Skip to content
Open
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
8 changes: 8 additions & 0 deletions __tests__/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export const I18nManager = {
swapLeftAndRightInRTL: (_value: boolean) => {},
};

export const Keyboard = {
addListener(_eventName: string, _listener: AnyFunction) {
return {
remove: () => {},
};
},
};

export const Dimensions = {
get(_what: "window" | "screen") {
return { fontScale: 2, height: 667, scale: 2, width: 375 };
Expand Down
230 changes: 228 additions & 2 deletions __tests__/integrations/keyboard.behavior.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, mock } from "bun:test";
import "../setup";

import * as React from "react";
import type { LayoutChangeEvent } from "react-native";
import { Keyboard, type LayoutChangeEvent, Platform } from "react-native";

import { useCombinedRef } from "../../src/hooks/useCombinedRef";
import { typedForwardRef } from "../../src/types.internal";
Expand All @@ -12,6 +12,30 @@ let lastAnimatedLegendListProps: any;
const reportContentInsetMock = mock(
(_insets: Partial<{ bottom: number; left: number; right: number; top: number }>) => {},
);
const keyboardDismissMock = mock(() => Promise.resolve());
const keyboardListeners = new Map<string, Set<() => void>>();
const keyboardAddListenerMock = mock((eventName: string, listener: () => void) => {
let listeners = keyboardListeners.get(eventName);

if (!listeners) {
listeners = new Set();
keyboardListeners.set(eventName, listeners);
}

listeners.add(listener);

return {
remove: mock(() => {
listeners?.delete(listener);
}),
};
});

const emitKeyboardEvent = (eventName: string) => {
for (const listener of keyboardListeners.get(eventName) ?? []) {
listener();
}
};

const createSharedValue = <T,>(initial: T) => {
let current = initial;
Expand Down Expand Up @@ -40,7 +64,7 @@ const createSharedValue = <T,>(initial: T) => {
mock.module("react-native-keyboard-controller", () => ({
KeyboardChatScrollView: (props: any) => React.createElement("keyboard-chat-scroll-view", props),
KeyboardController: {
dismiss: () => Promise.resolve(),
dismiss: keyboardDismissMock,
},
useKeyboardHandler: () => {},
}));
Expand Down Expand Up @@ -142,8 +166,35 @@ function ComposerInsetProbe({
return null;
}

function ScrollToEndProbe({
freeze,
listRef,
onResult,
useKeyboardScrollToEnd,
}: {
freeze: ReturnType<typeof createSharedValue<boolean>>;
listRef: React.RefObject<{ scrollToEnd(params?: { animated?: boolean }): Promise<void> } | null>;
onResult: (result: ReturnType<typeof useKeyboardScrollToEnd>) => void;
useKeyboardScrollToEnd: typeof import("../../src/integrations/keyboard").useKeyboardScrollToEnd;
}) {
const result = useKeyboardScrollToEnd({ freeze, listRef });

React.useEffect(() => {
onResult(result);
}, [onResult, result]);

return null;
}

describe("KeyboardAwareLegendList", () => {
const originalPlatform = Platform.OS;

beforeEach(() => {
Platform.OS = originalPlatform;
Keyboard.addListener = keyboardAddListenerMock as typeof Keyboard.addListener;
keyboardAddListenerMock.mockClear();
keyboardDismissMock.mockClear();
keyboardListeners.clear();
lastAnimatedLegendListProps = undefined;
reportContentInsetMock.mockClear();
});
Expand Down Expand Up @@ -267,4 +318,179 @@ describe("KeyboardAwareLegendList", () => {
expect(reportContentInsetMock).toHaveBeenCalledTimes(1);
expect(reportContentInsetMock).toHaveBeenNthCalledWith(1, { bottom: 42 });
});

it("keeps non-Android keyboard dismissal and scroll in the existing parallel path", async () => {
const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?ios-scroll-to-end-test");
const freeze = createSharedValue(false);
const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {});
let hookResult: ReturnType<typeof useKeyboardScrollToEnd> | undefined;

Platform.OS = "ios";

act(() => {
TestRenderer.create(
<ScrollToEndProbe
freeze={freeze}
listRef={{ current: { scrollToEnd } }}
onResult={(result) => {
hookResult = result;
}}
useKeyboardScrollToEnd={useKeyboardScrollToEnd}
/>,
);
});

await act(async () => {
await hookResult?.scrollMessageToEnd({ animated: true, closeKeyboard: true });
});

expect(keyboardDismissMock).toHaveBeenCalledTimes(1);
expect(keyboardAddListenerMock).not.toHaveBeenCalled();
expect(scrollToEnd).toHaveBeenCalledTimes(1);
expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true });
expect(freeze.value).toBe(false);
});

it("runs Android scroll after keyboard hide and layout settle", async () => {
const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?android-scroll-to-end-test");
const freeze = createSharedValue(false);
const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {});
let hookResult: ReturnType<typeof useKeyboardScrollToEnd> | undefined;

Platform.OS = "android";

act(() => {
TestRenderer.create(
<ScrollToEndProbe
freeze={freeze}
listRef={{ current: { scrollToEnd } }}
onResult={(result) => {
hookResult = result;
}}
useKeyboardScrollToEnd={useKeyboardScrollToEnd}
/>,
);
});

const scrollPromise = hookResult!.scrollMessageToEnd({ animated: true, closeKeyboard: true });

await Promise.resolve();

expect(keyboardDismissMock).toHaveBeenCalledTimes(1);
expect(keyboardAddListenerMock).toHaveBeenCalledWith("keyboardDidHide", expect.any(Function));
expect(scrollToEnd).not.toHaveBeenCalled();

emitKeyboardEvent("keyboardDidHide");

await act(async () => {
await scrollPromise;
});

expect(scrollToEnd).toHaveBeenCalledTimes(1);
expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true });
expect(freeze.value).toBe(false);
});

it("uses the Android keyboard hide fallback before the post-dismiss scroll", async () => {
const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?android-fallback-scroll-test");
const freeze = createSharedValue(false);
const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {});
let hookResult: ReturnType<typeof useKeyboardScrollToEnd> | undefined;

Platform.OS = "android";

act(() => {
TestRenderer.create(
<ScrollToEndProbe
freeze={freeze}
listRef={{ current: { scrollToEnd } }}
onResult={(result) => {
hookResult = result;
}}
useKeyboardScrollToEnd={useKeyboardScrollToEnd}
/>,
);
});

await act(async () => {
await hookResult?.scrollMessageToEnd({ animated: true, closeKeyboard: true });
});

expect(keyboardDismissMock).toHaveBeenCalledTimes(1);
expect(keyboardAddListenerMock).toHaveBeenCalledWith("keyboardDidHide", expect.any(Function));
expect(scrollToEnd).toHaveBeenCalledTimes(1);
expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true });
expect(freeze.value).toBe(false);
});

it("ignores stale Android scrolls when a newer send starts", async () => {
const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?stale-android-scroll-test");
const freeze = createSharedValue(false);
const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {});
let hookResult: ReturnType<typeof useKeyboardScrollToEnd> | undefined;

Platform.OS = "android";

act(() => {
TestRenderer.create(
<ScrollToEndProbe
freeze={freeze}
listRef={{ current: { scrollToEnd } }}
onResult={(result) => {
hookResult = result;
}}
useKeyboardScrollToEnd={useKeyboardScrollToEnd}
/>,
);
});

const firstScrollPromise = hookResult!.scrollMessageToEnd({ animated: true, closeKeyboard: true });

await Promise.resolve();

const secondScrollPromise = hookResult!.scrollMessageToEnd({ animated: true, closeKeyboard: true });

await Promise.resolve();
emitKeyboardEvent("keyboardDidHide");

await act(async () => {
await Promise.all([firstScrollPromise, secondScrollPromise]);
});

expect(scrollToEnd).toHaveBeenCalledTimes(1);
expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true });
expect(freeze.value).toBe(false);
});

it("keeps Android closeKeyboard false on the direct scroll path", async () => {
const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?android-no-close-scroll-test");
const freeze = createSharedValue(false);
const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {});
let hookResult: ReturnType<typeof useKeyboardScrollToEnd> | undefined;

Platform.OS = "android";

act(() => {
TestRenderer.create(
<ScrollToEndProbe
freeze={freeze}
listRef={{ current: { scrollToEnd } }}
onResult={(result) => {
hookResult = result;
}}
useKeyboardScrollToEnd={useKeyboardScrollToEnd}
/>,
);
});

await act(async () => {
await hookResult?.scrollMessageToEnd({ animated: true, closeKeyboard: false });
});

expect(keyboardDismissMock).not.toHaveBeenCalled();
expect(keyboardAddListenerMock).not.toHaveBeenCalled();
expect(scrollToEnd).toHaveBeenCalledTimes(1);
expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true });
expect(freeze.value).toBe(false);
});
});
29 changes: 19 additions & 10 deletions example/screens/examples/AiChatExample.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import { MaterialIcons } from "@expo/vector-icons";
import { useCallback, useRef } from "react";
import { Pressable, StyleSheet, type ViewProps } from "react-native";
import { Pressable, StyleSheet, View, type ViewProps } from "react-native";
import { KeyboardGestureArea, KeyboardProvider, KeyboardStickyView } from "react-native-keyboard-controller";
import Animated, { useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { KeyboardAwareLegendList, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import {
KeyboardAwareLegendList,
useKeyboardChatComposerInset,
useKeyboardScrollToEnd,
} from "@legendapp/list/keyboard";
import type { LegendListRef } from "@legendapp/list/react-native";
import { ChatComposer, getAiChatListProps, useAiChatExample } from "./chatShared";
import { SafeAreaShell } from "./shared";

export function AiChatExample() {
const listRef = useRef<LegendListRef>(null);
const composerRef = useRef<View>(null);
const insets = useSafeAreaInsets();
const isNearEnd = useSharedValue(true);
const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(listRef, composerRef);
const { scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const scrollMessageToEndCallback = useCallback(() => {
scrollMessageToEnd({ animated: true, closeKeyboard: true });
}, [scrollMessageToEnd]);

const { anchorIndex, input, messages, sendPrompt, setInput } = useAiChatExample({
scrollMessageToEnd: scrollMessageToEndCallback,
streamIntervalMs: 5,
Expand All @@ -38,7 +45,7 @@ export function AiChatExample() {
<SafeAreaShell>
<KeyboardGestureArea interpolator="ios" offset={60} style={{ flex: 1 }}>
<KeyboardAwareLegendList
freeze={freeze}
contentInsetEndAdjustment={contentInsetEndAdjustment}
keyboardDismissMode="interactive"
keyboardOffset={insets.bottom}
ref={listRef}
Expand All @@ -60,12 +67,14 @@ export function AiChatExample() {
</Animated.View>
</KeyboardGestureArea>
<KeyboardStickyView offset={{ closed: 0, opened: insets.bottom }}>
<ChatComposer
input={input}
onChangeText={setInput}
onPress={() => sendPrompt(input)}
placeholder="Ask about list behavior"
/>
<View collapsable={false} onLayout={onComposerLayout} ref={composerRef}>
<ChatComposer
input={input}
onChangeText={setInput}
onPress={() => sendPrompt(input)}
placeholder="Ask about list behavior"
/>
</View>
</KeyboardStickyView>
</SafeAreaShell>
</KeyboardProvider>
Expand Down
Loading