mattermost-mobile-upstream/app/hooks/use_keyboard_state_context/index.ts
Carlos Garcia f2857a97d1
Mm 67525 bump min version (#9640)
* bumps minSdkVersion to 30

Older versions are past end-of-life for security patches. voximplant service patch is also updated with minSdkVersion set to 30.

* removes manifest attributes no longer needed when minSdk is 30

* removes guard to for version lower than O

* removes permission check for versions lower than 28

* remove tests related to unsupported android sdk versions

* bumps iOS minSdkVersion to 18

* removes iOS 13/15 guards in hardware-keyboard

* removes iOS 15 guards in NotificationService

* removes iOS 15 guard in Gekidou network delegate

since gekidou is iOS only other platform guards are not needed

* removes version guard no longer needed in voximplant patch

* removes hasWriteStoragePermission dead stub and its call site

* removes unused setSoftKeyboardToAdjustResize from rnutils

The function had no call sites in JS/TS code and the iOS implementation
was a no-op.

* removes version guards and updates config after min version bump

* updates stale iOS 17 simulator references in CI to iOS 18.1

Co-Authored-By: Carlos Garcia <carlos.garcia@mattermost.com>

* fixes malformed patch file headers

- voximplant patch: restore missing diff --git header and index line
  for VIForegroundServiceModule.java, fix stale hunk counter (+11 -> +7)
- notifications patch: fix stale hunk line count (+91 -> +89) after
  KITKAT branch removal

Co-Authored-By: Carlos Garcia <carlos.garcia@mattermost.com>

* remove code below the supported OS versions

* Bump IntuneMAM SDK to 21.8.0 and MSAL to 2.15.0

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-09-16 10:53:06 +00:00

393 lines
15 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect} from 'react';
import {Platform} from 'react-native';
import {useSharedValue, withDelay, withTiming, type SharedValue} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {
InputContainerStateType,
StateMachineEventType,
type ActionUpdates,
type InputContainerState,
type SharedValueUpdate,
type StateContext,
type StateEvent,
type StateSnapshot,
} from '@keyboard';
import {KEYBOARD_TRANSITION_DURATION} from '@keyboard/constants';
import {getStateEntryAction, getStateExitAction} from '@keyboard/state_machine/actions';
import {calculateAdjustedHeight} from '@keyboard/state_machine/keyboard_utils';
import {findTransition} from '@keyboard/state_machine/transitions';
type UseKeyboardStateContextConfig = {
tabBarHeight: number;
enabled: boolean;
};
export type KeyboardStateContextReturn = StateContext & {
processEvent: (event: StateEvent) => void;
isEmojiPickerActive: () => boolean;
forceResetToIdle: () => void;
};
export const DEFAULT_POST_INPUT_HEIGHT = 95;
const isAndroid = Platform.OS === 'android';
export function useKeyboardStateContext(config: UseKeyboardStateContextConfig): KeyboardStateContextReturn {
const safeAreaInsets = useSafeAreaInsets();
// Height tracking SharedValues
const inputAccessoryHeight = useSharedValue(0);
const targetHeight = useSharedValue(0);
const keyboardEventHeight = useSharedValue(0);
const preSearchHeight = useSharedValue(0);
const currentState = useSharedValue<InputContainerState>(InputContainerStateType.IDLE);
const hasZeroKeyboardHeight = useSharedValue(false);
const isDraggingKeyboard = useSharedValue(false);
const isWaitingForKeyboard = useSharedValue(false);
// Guard for emoji picker transitions - blocks keyboard event handlers
const isEmojiPickerTransition = useSharedValue(false);
const isEmojiSearchActive = useSharedValue(false);
// Reconciler pause flag - explicit control over when reconciler should sync values
const isReconcilerPaused = useSharedValue(false);
// Enabled flag - allows disabling keyboard event processing when screen is not focused
const isEnabled = useSharedValue(config.enabled);
// Spurious event detection - tracks maximum progress to detect backwards progress
const maxKeyboardProgress = useSharedValue(0);
// Scroll adjustment SharedValues
const postInputTranslateY = useSharedValue(0); // Input container position (also drives scroll padding)
const scrollOffset = useSharedValue(0); // Scroll offset for iOS contentInset compensation
const scrollPosition = useSharedValue(0); // Current scroll position of the list
const cursorPosition = useSharedValue(0);
// Height adjustments (reactive to rotation)
const tabBarHeight = useSharedValue(config.tabBarHeight);
const safeAreaBottom = useSharedValue(safeAreaInsets.bottom);
// Last keyboard height as SharedValue (required for worklet mutations)
const lastKeyboardHeight = useSharedValue(0);
// Used for setting KeyboardGestureArea extra offset
const postInputContainerHeight = useSharedValue(DEFAULT_POST_INPUT_HEIGHT);
// Update SharedValues when config or insets change (e.g., rotation)
useEffect(() => {
tabBarHeight.value = config.tabBarHeight;
}, [config.tabBarHeight, tabBarHeight]);
// Helper: Create snapshot by reading all SharedValues
const createStateSnapshot = (): StateSnapshot => {
'worklet';
return {
inputAccessoryHeight: inputAccessoryHeight.value,
targetHeight: targetHeight.value,
postInputTranslateY: postInputTranslateY.value,
scrollOffset: scrollOffset.value,
lastKeyboardHeight: lastKeyboardHeight.value,
keyboardEventHeight: keyboardEventHeight.value,
preSearchHeight: preSearchHeight.value,
postInputContainerHeight: postInputContainerHeight.value,
scrollPosition: scrollPosition.value,
hasZeroKeyboardHeight: hasZeroKeyboardHeight.value,
isDraggingKeyboard: isDraggingKeyboard.value,
isEmojiPickerTransition: isEmojiPickerTransition.value,
isEmojiSearchActive: isEmojiSearchActive.value,
isWaitingForKeyboard: isWaitingForKeyboard.value,
currentState: currentState.value,
tabBarHeight: tabBarHeight.value,
safeAreaBottom: safeAreaBottom.value,
};
};
// Helper: Apply updates to SharedValues
const applyUpdates = (updates: ActionUpdates | void): void => {
'worklet';
if (!updates) {
return;
}
const sharedValueMap: Record<string, SharedValue<number | boolean | string>> = {
inputAccessoryHeight,
targetHeight,
postInputTranslateY,
scrollOffset,
lastKeyboardHeight,
keyboardEventHeight,
preSearchHeight,
scrollPosition,
hasZeroKeyboardHeight,
isDraggingKeyboard,
isEmojiPickerTransition,
isEmojiSearchActive,
isWaitingForKeyboard,
maxKeyboardProgress,
isReconcilerPaused,
currentState,
};
for (const [key, updateValue] of Object.entries(updates)) {
const update = updateValue as SharedValueUpdate;
if (!update) {
continue;
}
const sharedValue = sharedValueMap[key];
if (!sharedValue || typeof sharedValue.value === 'undefined') {
continue;
}
if (update.animated && typeof update.value === 'number') {
const duration = update.duration || KEYBOARD_TRANSITION_DURATION;
sharedValue.value = withTiming(update.value, {duration});
} else {
sharedValue.value = update.value;
}
}
};
// Main function: Process state machine events
const processEvent = (event: StateEvent): void => {
'worklet';
// Check if keyboard event processing is enabled
if (!isEnabled.value) {
return;
}
// Read current state BEFORE preprocessing
const currentStateValue = currentState.value;
// Calculate adjusted height if rawHeight is provided
if (event.rawHeight !== undefined) {
const adjustedHeight = calculateAdjustedHeight(
lastKeyboardHeight.value,
tabBarHeight.value,
safeAreaBottom.value,
event.rawHeight,
);
event.height = adjustedHeight;
const shouldSkipFakeEventCheck = isAndroid || isDraggingKeyboard.value || isEmojiSearchActive.value;
if (!shouldSkipFakeEventCheck) {
let currentProgress = event.progress ?? 0;
if (currentProgress > 1) {
return;
}
if (event.type === StateMachineEventType.KEYBOARD_EVENT_START) {
currentProgress = 0;
}
const maxProgress = maxKeyboardProgress.value;
const isKeyboardEvent = event.type === StateMachineEventType.KEYBOARD_EVENT_START ||
event.type === StateMachineEventType.KEYBOARD_EVENT_MOVE ||
event.type === StateMachineEventType.KEYBOARD_EVENT_END;
if (isKeyboardEvent) {
const hasReachedCompletion = maxProgress >= 0.99;
const isBackwardsProgress = currentProgress <= maxProgress * 0.95;
if (hasReachedCompletion && isBackwardsProgress) {
return;
}
}
if (isKeyboardEvent && currentProgress > maxProgress) {
maxKeyboardProgress.value = currentProgress;
}
if (event.type === StateMachineEventType.KEYBOARD_EVENT_END) {
maxKeyboardProgress.value = withDelay(250, withTiming(0, {duration: 0}));
}
}
if (event.type === StateMachineEventType.KEYBOARD_EVENT_START) {
keyboardEventHeight.value = adjustedHeight;
if (adjustedHeight > 75) {
isWaitingForKeyboard.value = false;
}
}
if ((event.type === StateMachineEventType.KEYBOARD_EVENT_START ||
event.type === StateMachineEventType.KEYBOARD_EVENT_END) &&
!isEmojiPickerTransition.value) {
hasZeroKeyboardHeight.value = adjustedHeight < 75;
}
}
// KEYBOARD_EVENT_START specific checks
if (event.type === StateMachineEventType.KEYBOARD_EVENT_START) {
if (event.progress === 1 && currentStateValue === InputContainerStateType.IDLE) {
return;
}
if (event.rawHeight === 0 && currentStateValue === InputContainerStateType.IDLE) {
event.type = StateMachineEventType.KEYBOARD_EVENT_END;
}
}
// During emoji picker transitions, block most keyboard events
if (isEmojiPickerTransition.value && !event.type.startsWith('USER_')) {
const {type} = event;
const allowedStatesForEnd = (
currentStateValue === InputContainerStateType.KEYBOARD_TO_EMOJI ||
currentStateValue === InputContainerStateType.EMOJI_TO_KEYBOARD ||
currentStateValue === InputContainerStateType.EMOJI_SEARCH_ACTIVE ||
currentStateValue === InputContainerStateType.EMOJI_PICKER_OPEN
);
const allowStartForHeightCapture = type === StateMachineEventType.KEYBOARD_EVENT_START &&
currentStateValue === InputContainerStateType.EMOJI_PICKER_OPEN;
const allowEnd = type === StateMachineEventType.KEYBOARD_EVENT_END && allowedStatesForEnd;
const allowedEmojiStateForStartOrMove = currentStateValue === InputContainerStateType.EMOJI_TO_KEYBOARD ||
currentStateValue === InputContainerStateType.EMOJI_SEARCH_ACTIVE ||
currentStateValue === InputContainerStateType.EMOJI_PICKER_OPEN;
const allowMove = type === StateMachineEventType.KEYBOARD_EVENT_MOVE && allowedEmojiStateForStartOrMove;
const allowStart = type === StateMachineEventType.KEYBOARD_EVENT_START && allowedEmojiStateForStartOrMove;
if (!allowEnd && !allowMove && !allowStart && !allowStartForHeightCapture) {
return;
}
}
const snapshot = createStateSnapshot();
const eventType = event.type;
const transition = findTransition(snapshot.currentState, eventType, snapshot, event);
if (!transition) {
return;
}
const nextState = transition.to;
if (transition.guard && !transition.guard(snapshot, event)) {
return;
}
try {
currentState.value = nextState;
const exitAction = getStateExitAction(snapshot.currentState, nextState);
if (exitAction) {
const exitUpdates = exitAction(snapshot, event);
applyUpdates(exitUpdates);
}
if (transition.action) {
const transitionUpdates = transition.action(snapshot, event);
applyUpdates(transitionUpdates);
}
// Entry action runs LAST so it has final authority over the target state's flags.
// Transition actions may set flags needed during the transition (e.g. isEmojiPickerTransition=true
// in EMOJI_PICKER_OPEN→IDLE to protect scroll offset while heights animate to 0), but the
// entry action of the target state must be able to override them (e.g. enterIdle clears
// isEmojiPickerTransition so subsequent keyboard events are not blocked in IDLE).
// Entry actions use the immutable snapshot for their logic, so running after the transition
// action does not affect their computations.
if (snapshot.currentState !== nextState) {
const entryAction = getStateEntryAction(nextState);
if (entryAction) {
const entryUpdates = entryAction(snapshot, event, snapshot.currentState);
applyUpdates(entryUpdates);
}
}
} catch (error) {
currentState.value = snapshot.currentState;
}
};
// Check if emoji picker is active
const isEmojiPickerActiveFunc = (): boolean => {
'worklet';
const state = currentState.value;
return state === InputContainerStateType.EMOJI_PICKER_OPEN ||
state === InputContainerStateType.KEYBOARD_TO_EMOJI ||
state === InputContainerStateType.EMOJI_TO_KEYBOARD ||
state === InputContainerStateType.EMOJI_SEARCH_ACTIVE;
};
// Force the state machine to IDLE regardless of current state.
// Called when the keyboard context is disabled (screen navigates away) to ensure
// the machine doesn't stay stuck in KEYBOARD_OPEN if the dismiss animation events
// arrive after isEnabled is already false (common on edge-to-edge Android).
const forceResetToIdle = (): void => {
'worklet';
if (currentState.value === InputContainerStateType.IDLE) {
return;
}
currentState.value = InputContainerStateType.IDLE;
// Reset all height/position values
targetHeight.value = 0;
postInputTranslateY.value = 0;
inputAccessoryHeight.value = 0;
// Reset flags
isReconcilerPaused.value = false;
isWaitingForKeyboard.value = false;
isEmojiPickerTransition.value = false;
isEmojiSearchActive.value = false;
isDraggingKeyboard.value = false;
maxKeyboardProgress.value = 0;
hasZeroKeyboardHeight.value = false;
};
// Return all SharedValues + functions
return {
// SharedValues
inputAccessoryHeight,
targetHeight,
keyboardEventHeight,
preSearchHeight,
lastKeyboardHeight,
postInputContainerHeight,
hasZeroKeyboardHeight,
isDraggingKeyboard,
isWaitingForKeyboard,
currentState,
isEmojiPickerTransition,
isEmojiSearchActive,
isReconcilerPaused,
isEnabled,
maxKeyboardProgress,
postInputTranslateY,
scrollOffset,
scrollPosition,
cursorPosition,
tabBarHeight,
safeAreaBottom,
// Functions
processEvent,
isEmojiPickerActive: isEmojiPickerActiveFunc,
forceResetToIdle,
};
}