mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 19:00:14 +00:00
* Expo-Router partial migration * Full migration to ExpoRouter * i18n * add tests * Merge branch 'main' into expo-router * align package-lock.json * i18n * feedback review * feedback review * android 16kb patch * Refactored emoji picker keyboard using a state machine * set android launch_screen in drawables instead of layout * no conditional hooks * remove duplicate call in test * address feedback * fix tests and address more feedback * small corrections * small corrections * small corrections * fix draft test * fix draft test 2 * remove navigateToScreen in draff test * fix nit in draft test * remove doc file * Feedback review * fix snapshots * improve test coverage * fix i18n * Upgrade t o RN 0.83, other dependencies and new architecture (#9737) * library hardware-keyboard android new arch support fix * library rnshare android new arch support fix * library rnutils android new arch support fix * library secure-pdf-viewer add new arch support * Enable new arch on iOS and Android * update emm and network client with new-arch * update intune to use new-arch * performance improvements * fix tests * fixes post input on new-arch * fix android emoji picker * remove doc * run tests in parallel * upgrade to React Native 0.83.9, Expo 55, React 19, and New Architecture Bumps core dependencies to their latest versions: - React Native 0.77.3 → 0.83.9 (New Architecture enabled) - React 18.3.1 → 19.2.5 - Expo SDK 52 → 55 - TypeScript 5.8.3 → 6.0.3 - Node.js 22.16.0 → 24.15.0 - react-native-reanimated 3.17.3 → 4.3.0 - Various other dependency upgrades Migrates iOS AppDelegate from Objective-C to Swift, updates Android build tooling and Gradle wrapper, refreshes all patches and font assets, and adapts app code, tests, and CI workflows throughout for API changes introduced by these upgrades. * remove unecessary dismissKeyboard in notifications * feedback review * update intune to use new-arch * update @mattermost deps * missing i18n string * additional fixes * update fastlane
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {act, renderHook} from '@testing-library/react-native';
|
|
import React, {type PropsWithChildren} from 'react';
|
|
import {Platform} from 'react-native';
|
|
|
|
import {KeyboardStateProvider, useKeyboardState} from '@context/keyboard_state';
|
|
import {InputContainerStateType} from '@keyboard';
|
|
|
|
const mockStateContext = {
|
|
isEnabled: {value: false},
|
|
cursorPosition: {value: 0},
|
|
postInputContainerHeight: {value: 91},
|
|
currentState: {value: InputContainerStateType.IDLE},
|
|
targetHeight: {value: 0},
|
|
inputAccessoryHeight: {value: 0},
|
|
keyboardEventHeight: {value: 0},
|
|
preSearchHeight: {value: 0},
|
|
lastKeyboardHeight: {value: 0},
|
|
hasZeroKeyboardHeight: {value: false},
|
|
isDraggingKeyboard: {value: false},
|
|
isWaitingForKeyboard: {value: false},
|
|
isEmojiPickerTransition: {value: false},
|
|
isEmojiSearchActive: {value: false},
|
|
isReconcilerPaused: {value: false},
|
|
maxKeyboardProgress: {value: 0},
|
|
postInputTranslateY: {value: 0},
|
|
scrollOffset: {value: 0},
|
|
scrollPosition: {value: 0},
|
|
tabBarHeight: {value: 0},
|
|
safeAreaBottom: {value: 0},
|
|
processEvent: jest.fn(),
|
|
isEmojiPickerActive: jest.fn(() => false),
|
|
};
|
|
|
|
jest.mock('@hooks/use_keyboard_state_context', () => ({
|
|
useKeyboardStateContext: jest.fn(() => mockStateContext),
|
|
}));
|
|
|
|
const mockStateMachine = {
|
|
onUserFocusInput: jest.fn(),
|
|
onUserOpenEmoji: jest.fn(),
|
|
onUserCloseEmoji: jest.fn(),
|
|
onUserFocusEmojiSearch: jest.fn(),
|
|
onUserBlurEmojiSearch: jest.fn(),
|
|
isEmojiPickerActive: jest.fn(() => false),
|
|
};
|
|
|
|
jest.mock('@hooks/use_keyboard_state_machine', () => ({
|
|
useKeyboardStateMachine: jest.fn(() => mockStateMachine),
|
|
}));
|
|
|
|
jest.mock('@hooks/use_keyboard_events', () => ({
|
|
useKeyboardEvents: jest.fn(),
|
|
}));
|
|
|
|
const mockUseIsTablet = jest.fn(() => false);
|
|
jest.mock('@hooks/device', () => ({
|
|
useIsTablet: () => mockUseIsTablet(),
|
|
}));
|
|
|
|
const mockDismissKeyboard = jest.fn(() => Promise.resolve());
|
|
jest.mock('@utils/keyboard', () => ({
|
|
dismissKeyboard: () => mockDismissKeyboard(),
|
|
}));
|
|
|
|
function makeWrapper(props: {tabBarHeight?: number; enabled?: boolean} = {}) {
|
|
const {tabBarHeight = 49, enabled = true} = props;
|
|
return function Wrapper({children}: PropsWithChildren) {
|
|
return (
|
|
<KeyboardStateProvider
|
|
tabBarHeight={tabBarHeight}
|
|
enabled={enabled}
|
|
>
|
|
{children}
|
|
</KeyboardStateProvider>
|
|
);
|
|
};
|
|
}
|
|
|
|
describe('KeyboardStateProvider', () => {
|
|
beforeEach(() => {
|
|
// Use fake timers for all tests to prevent pending timers (the 250ms
|
|
// setTimeout in setIsEmojiSearchFocused and the enabled-sync effect)
|
|
// from firing after the test ends and triggering unwrapped state updates.
|
|
jest.useFakeTimers({doNotFake: ['nextTick']});
|
|
jest.clearAllMocks();
|
|
|
|
// Reset shared value side-effects between tests
|
|
mockStateContext.isEnabled.value = false;
|
|
mockStateContext.cursorPosition.value = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => {
|
|
jest.runAllTimers();
|
|
});
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
describe('enabled prop → stateContext.isEnabled', () => {
|
|
it('should sync the enabled prop to stateContext.isEnabled.value on mount', () => {
|
|
renderHook(() => useKeyboardState(), {wrapper: makeWrapper({enabled: true})});
|
|
|
|
expect(mockStateContext.isEnabled.value).toBe(true);
|
|
});
|
|
|
|
it('should not update stateContext.isEnabled.value when unmounted before effect runs', () => {
|
|
// When enabled=false (default), isEnabled.value stays false after unmount
|
|
const {unmount} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper({enabled: false})});
|
|
|
|
unmount();
|
|
|
|
expect(mockStateContext.isEnabled.value).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('setIsEmojiSearchFocused', () => {
|
|
it('should call stateMachine.onUserFocusEmojiSearch when focusing and not already focused', () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
act(() => {
|
|
result.current.setIsEmojiSearchFocused(true);
|
|
});
|
|
|
|
expect(mockStateMachine.onUserFocusEmojiSearch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should NOT call stateMachine.onUserFocusEmojiSearch when already focused', () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
// First focus call — sets isEmojiSearchFocused → true after 250ms
|
|
act(() => {
|
|
result.current.setIsEmojiSearchFocused(true);
|
|
});
|
|
act(() => {
|
|
jest.advanceTimersByTime(300);
|
|
});
|
|
|
|
mockStateMachine.onUserFocusEmojiSearch.mockClear();
|
|
|
|
// Second focus call when already focused — should NOT call onUserFocusEmojiSearch
|
|
act(() => {
|
|
result.current.setIsEmojiSearchFocused(true);
|
|
});
|
|
|
|
expect(mockStateMachine.onUserFocusEmojiSearch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call stateMachine.onUserBlurEmojiSearch when blurring and currently focused', () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
// Focus first, then wait for state to update
|
|
act(() => {
|
|
result.current.setIsEmojiSearchFocused(true);
|
|
});
|
|
act(() => {
|
|
jest.advanceTimersByTime(300);
|
|
});
|
|
|
|
mockStateMachine.onUserBlurEmojiSearch.mockClear();
|
|
|
|
act(() => {
|
|
result.current.setIsEmojiSearchFocused(false);
|
|
});
|
|
|
|
expect(mockStateMachine.onUserBlurEmojiSearch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should NOT call stateMachine.onUserBlurEmojiSearch when not focused', () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
act(() => {
|
|
result.current.setIsEmojiSearchFocused(false);
|
|
});
|
|
|
|
expect(mockStateMachine.onUserBlurEmojiSearch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should pass asHardwareKeyboard=true on iOS tablet with keyboard height=0 and state=0', () => {
|
|
const originalOS = Platform.OS;
|
|
(Platform as {OS: string}).OS = 'ios';
|
|
mockUseIsTablet.mockReturnValue(true);
|
|
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
act(() => {
|
|
result.current.setIsEmojiSearchFocused(true);
|
|
});
|
|
|
|
expect(mockStateMachine.onUserFocusEmojiSearch).toHaveBeenCalledWith(true);
|
|
|
|
(Platform as {OS: string}).OS = originalOS;
|
|
mockUseIsTablet.mockReturnValue(false);
|
|
});
|
|
|
|
it('should pass asHardwareKeyboard=false on Android', () => {
|
|
const originalOS = Platform.OS;
|
|
(Platform as {OS: string}).OS = 'android';
|
|
mockUseIsTablet.mockReturnValue(true);
|
|
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
act(() => {
|
|
result.current.setIsEmojiSearchFocused(true);
|
|
});
|
|
|
|
expect(mockStateMachine.onUserFocusEmojiSearch).toHaveBeenCalledWith(false);
|
|
|
|
(Platform as {OS: string}).OS = originalOS;
|
|
mockUseIsTablet.mockReturnValue(false);
|
|
});
|
|
});
|
|
|
|
describe('closeInputAccessoryView', () => {
|
|
it('should call dismissKeyboard AND stateMachine.onUserCloseEmoji when isEmojiSearchFocused=true', async () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
act(() => {
|
|
result.current.setIsEmojiSearchFocused(true);
|
|
});
|
|
act(() => {
|
|
jest.advanceTimersByTime(300);
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.closeInputAccessoryView();
|
|
});
|
|
|
|
expect(mockDismissKeyboard).toHaveBeenCalledTimes(1);
|
|
expect(mockStateMachine.onUserCloseEmoji).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should only call stateMachine.onUserCloseEmoji and NOT dismissKeyboard when isEmojiSearchFocused=false', () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
act(() => {
|
|
result.current.closeInputAccessoryView();
|
|
});
|
|
|
|
expect(mockDismissKeyboard).not.toHaveBeenCalled();
|
|
expect(mockStateMachine.onUserCloseEmoji).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('blurAndDismissKeyboard', () => {
|
|
it('should delegate to closeInputAccessoryView when showInputAccessoryView=true', async () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
act(() => {
|
|
result.current.setShowInputAccessoryView(true);
|
|
});
|
|
|
|
mockDismissKeyboard.mockClear();
|
|
mockStateMachine.onUserCloseEmoji.mockClear();
|
|
|
|
// closeInputAccessoryView awaits a 250ms setTimeout internally — advance timers inside act
|
|
const promise = result.current.blurAndDismissKeyboard();
|
|
act(() => {
|
|
jest.advanceTimersByTime(300);
|
|
});
|
|
await act(async () => {
|
|
await promise;
|
|
});
|
|
|
|
// closeInputAccessoryView dispatches onUserCloseEmoji
|
|
expect(mockStateMachine.onUserCloseEmoji).toHaveBeenCalledTimes(1);
|
|
|
|
// dismissKeyboard is NOT called directly (closeInputAccessoryView handles it conditionally)
|
|
expect(mockDismissKeyboard).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call dismissKeyboard when showInputAccessoryView=false', async () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
await act(async () => {
|
|
await result.current.blurAndDismissKeyboard();
|
|
});
|
|
|
|
expect(mockDismissKeyboard).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('registerPostInputCallbacks', () => {
|
|
it('should make the updateValue callback available via context', () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
const updateValueFn = jest.fn();
|
|
const updateCursorPositionFn = jest.fn();
|
|
|
|
act(() => {
|
|
result.current.registerPostInputCallbacks(updateValueFn, updateCursorPositionFn);
|
|
});
|
|
|
|
expect(result.current.updateValue).toBe(updateValueFn);
|
|
});
|
|
|
|
it('should make the updateCursorPosition callback available via context', () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
const updateValueFn = jest.fn();
|
|
const updateCursorPositionFn = jest.fn();
|
|
|
|
act(() => {
|
|
result.current.registerPostInputCallbacks(updateValueFn, updateCursorPositionFn);
|
|
});
|
|
|
|
expect(result.current.updateCursorPosition).toBe(updateCursorPositionFn);
|
|
});
|
|
|
|
it('should initialize stateContext.cursorPosition.value to the current value length', () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
const currentText = 'hello world'; // length = 11
|
|
|
|
act(() => {
|
|
result.current.registerPostInputCallbacks(jest.fn(), jest.fn(), currentText);
|
|
});
|
|
|
|
expect(mockStateContext.cursorPosition.value).toBe(currentText.length);
|
|
});
|
|
});
|
|
|
|
describe('getCursorPosition', () => {
|
|
it('should return the current value of stateContext.cursorPosition', () => {
|
|
mockStateContext.cursorPosition.value = 42;
|
|
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
expect(result.current.getCursorPosition()).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe('setCursorPosition', () => {
|
|
it('should update stateContext.cursorPosition.value to the provided position', () => {
|
|
const {result} = renderHook(() => useKeyboardState(), {wrapper: makeWrapper()});
|
|
|
|
act(() => {
|
|
result.current.setCursorPosition(99);
|
|
});
|
|
|
|
expect(mockStateContext.cursorPosition.value).toBe(99);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// useKeyboardState — used OUTSIDE provider (fallback values)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('useKeyboardState (outside provider)', () => {
|
|
it('should return showInputAccessoryView=false', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(result.current.showInputAccessoryView).toBe(false);
|
|
});
|
|
|
|
it('should return isEmojiSearchFocused=false', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(result.current.isEmojiSearchFocused).toBe(false);
|
|
});
|
|
|
|
it('should return updateValue=null', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(result.current.updateValue).toBeNull();
|
|
});
|
|
|
|
it('should return updateCursorPosition=null', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(result.current.updateCursorPosition).toBeNull();
|
|
});
|
|
|
|
it('should return postInputContainerHeight=0', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(result.current.postInputContainerHeight).toBe(0);
|
|
});
|
|
|
|
it('should return a no-op setShowInputAccessoryView that does not throw', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(() => result.current.setShowInputAccessoryView(true)).not.toThrow();
|
|
});
|
|
|
|
it('should return a no-op setIsEmojiSearchFocused that does not throw', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(() => result.current.setIsEmojiSearchFocused(true)).not.toThrow();
|
|
});
|
|
|
|
it('should return getCursorPosition that always returns 0', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(result.current.getCursorPosition()).toBe(0);
|
|
});
|
|
|
|
it('should return a no-op setCursorPosition that does not throw', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(() => result.current.setCursorPosition(10)).not.toThrow();
|
|
});
|
|
|
|
it('should return a no-op async blurAndDismissKeyboard that resolves without error', async () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
await expect(result.current.blurAndDismissKeyboard()).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('should return a no-op closeInputAccessoryView that does not throw', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(() => result.current.closeInputAccessoryView()).not.toThrow();
|
|
});
|
|
|
|
it('should return a no-op registerPostInputCallbacks that does not throw', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
const noop = jest.fn();
|
|
expect(() => result.current.registerPostInputCallbacks(noop, noop)).not.toThrow();
|
|
});
|
|
|
|
it('should return listRef with current=null', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(result.current.listRef.current).toBeNull();
|
|
});
|
|
|
|
it('should return inputRef with current=null', () => {
|
|
const {result} = renderHook(() => useKeyboardState());
|
|
expect(result.current.inputRef.current).toBeNull();
|
|
});
|
|
});
|