Compare commits

...

14 commits

Author SHA1 Message Date
unified-ci-app[bot]
4ef7d5d6fe
Bump app build number to 717 (#9487)
Some checks failed
github-release / test (push) Has been cancelled
github-release / build-ios-unsigned (push) Has been cancelled
github-release / build-android-unsigned (push) Has been cancelled
github-release / generate-consolidated-sbom (push) Has been cancelled
github-release / release (push) Has been cancelled
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-02 14:05:04 +02:00
Mattermost Build
76031bf086
At mentioned from quick action respect cursor position (#9425) (#9477)
* At mentioned from quick action respect cursor position

* Reverted the unnecessary changes

(cherry picked from commit 20a70ae64d)

Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com>
2026-01-28 09:35:16 +02:00
unified-ci-app[bot]
fc6eeec6c7
Bump app build number to 715 (#9472)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-27 08:47:29 +02:00
Mattermost Build
63dcc2fce3
Fix: Keyboard opens on android when clicked on switch to keyboard btn in emoji picker (#9464) (#9471)
(cherry picked from commit 98bf396714)

Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com>
2026-01-26 15:02:15 +02:00
Mattermost Build
bfeabe1c4e
Fix emoji picker section goes blank when User chooses Skin tone (#9436) (#9463)
* Fix emoji picker section goes blank when User chooses Skin tone

* Removed the platform check

(cherry picked from commit a3dc1a1bdc)

Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com>
2026-01-23 08:26:06 +02:00
unified-ci-app[bot]
2214f06e88
Bump app build number to 713 (#9462)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-22 09:37:29 +02:00
Mattermost Build
73a83b6d5a
fix(): update INTUNE_ENABLED within Github CI & Podfile (#9453) (#9460)
* fix(): update INTUNE_ENABLED within Github CI & Podfile
* temporary switch to iOS 15 and false INTUNE_ENABLED
* revert INTUNE_ENABLED='false' and iOS 15

(cherry picked from commit 6fb109faf1)

Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
2026-01-22 08:34:07 +02:00
unified-ci-app[bot]
6142021573
Bump app build number to 710 (#9451)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-21 08:32:07 +02:00
Mattermost Build
22700cb3cb
Intune release clientID (#9440) (#9450)
(cherry picked from commit bd1dcef010)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-21 08:18:51 +02:00
Rahim Rahman
c9269ceb58
feat(): update iOS min version to 16 (#9432) (#9445) 2026-01-21 08:08:25 +02:00
unified-ci-app[bot]
033acd2baa
Bump app build and version number (#9435)
* Bump app build number to 706

* Bump app version number to 2.37.0

---------

Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-20 10:59:01 +02:00
Mattermost Build
1c4368ed7d
fix(MM-67224): MM Mobile v2.36.0 crash on iOS 16 and below (#9421) (#9433)
* fix(MM-67224): iOS app hanging for at least 2000 ms.

* downgrade intune iOS SDK to 20.9.0

* update to version 20.9.2 (latest)

* reverse changes to .env files

* set INTUNE_IOS_SDK_VERSION to 20.9.2

* revert change

* Intune update

* remove echo

(cherry picked from commit 4324846736)

Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
2026-01-20 08:21:01 +02:00
Mattermost Build
755e9475fc
chore: Update NOTICE.txt file with updated dependencies (#9424) (#9426)
(cherry picked from commit 5e4b81f77a)

Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com>
2026-01-19 12:52:10 +02:00
Mattermost Build
e74b07051e
New keyboard library Setup (#9278) (#9413)
* Initial setup for new keyboard

* fix the offset calculation onMove by adding isKeyboardFullyOpened

* Done with the keyboard handling implementation

* Handled keyboard focus and blured state using context

* Added default height for input container

* Android support

* Tablet state handling

* Fix for refreshing offset in list

* Created a default context for mention post list

* Fix linter errors

* Fix tests

* Minor

* Fix the height issue for tablet view

* Review comments

* Dependency fix

* Reveiw comment

* keyboard animation only enabled with screen on top navigation

* added tests

* Added extra keyboard component for emoji picker (#9328)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Check for stale event for keyboard geature area

* fix keyboard stale event for mid-gesture change swipe direction

* Closing emoji picker when message priority is opened

* changing to thread view closes emoji picker

* Close emoij picker and keyboard when attachment icons are clicked

* handle android emoji picker behaviour

* Remove emoji picker code

* fix tests

* Address reviev comments

* Test fixes

* usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag

* File attachments option in bottom sheet quick action (#9331)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* File attachments option in bottom sheet quick action

* Updated tests

* i18n

* fix test

* Added tests

* Review comment

* fix tests

* Integrated Emoji picker to Extra keyboard component.  (#9339)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Fix the post input container height change issue

* Added emoji picker with search functionality

* keyboard height not recorded fallback to default height, set search to false closing emoji picker

* fix search funcitonality in android

* scroll to end bottom dismissed with pressed

* Fixed the scroll issue for android

* fix the flickering post input issue

* Wired up the emoji picker

* Added keyboard and spaceback in emoji picker

* intl extract

* initial cursor position and simple calculation review comment

* separated grapheme to utils file

* Review comments

* nitpick

* Fix the bottom safe area and navigation stack restore fix

* Fix test

* disabled emoji picker in edit post screen

* change the name of the variable to name it clarier

* fix the tab gap issue in andriod

* disabled the emoji skin tone change on andriod

* fix the input container jump issue

* fix the failing test

* UX review

* added white space after the attachment icon

* When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open

* back bottom closes emoji picker and opening keyboard jump fix

* reverted code

* fixed the flickering issue of input and scroll position fix

* Fix the gap issue in thread

* fix the warning reaminated issue when opening a channel

* Fix the extra space issue between input and emoij picker

* Minor fix for extra space between input and emoji picker

* Fix thread view bottom safe area and gap between keyboard and searched emojis

* Fix the keyboard issue in tablet

* Fix the warning issue react-native-keyboard-controller

* Fix tests

* Fix the tablet search hight issue

* Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink

* Bottom sheet jump issue resolved

* Search do not close after selecting emoji in search list

* Added the bottom inset height in search emoji for android

* fix buid issue

* Fix the cancel state to emoji picker

* use portals for autocomplete

* fix permalink extra space in post list

* Portal only for android

---------





(cherry picked from commit e39b27be7e)

Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-14 08:18:06 +02:00
104 changed files with 7195 additions and 822 deletions

View file

@ -52,7 +52,7 @@ runs:
- name: ci/install-pods-dependencies
shell: bash
env:
INTUNE_ENABLED: ${{ inputs.intune-enabled == 'true' && '1' || '0' }}
INTUNE_ENABLED: ${{ inputs.intune-enabled }}
run: |
echo "::group::install-pods-dependencies"
echo "INTUNE_ENABLED=$INTUNE_ENABLED"

View file

@ -11,7 +11,7 @@ on:
env:
NODE_VERSION: 22.14.0
TERM: xterm
INTUNE_ENABLED: 1
INTUNE_ENABLED: 'true'
jobs:
test:
@ -34,7 +34,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: 'true'
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-simulator
@ -70,7 +70,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: 'true'
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-and-deploy-ios-beta

View file

@ -11,7 +11,7 @@ on:
env:
NODE_VERSION: 22.14.0
TERM: xterm
INTUNE_ENABLED: 1
INTUNE_ENABLED: 'true'
jobs:
test:
@ -39,7 +39,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: 'true'
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-and-deploy-ios-release
@ -82,7 +82,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: 'true'
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-simulator

View file

@ -8,7 +8,7 @@ on:
env:
NODE_VERSION: 22.14.0
TERM: xterm
INTUNE_ENABLED: 1
INTUNE_ENABLED: 'true'
jobs:
test:
@ -41,7 +41,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: 'true'
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-pr

View file

@ -15,7 +15,7 @@ concurrency:
cancel-in-progress: true
env:
INTUNE_ENABLED: 1
INTUNE_ENABLED: 'true'
jobs:
update-initial-status-ios:
@ -59,7 +59,7 @@ jobs:
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: 'true'
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Build iOS Simulator

View file

@ -6,7 +6,7 @@ on:
- release-*
env:
INTUNE_ENABLED: 1
INTUNE_ENABLED: 'true'
jobs:
update-initial-status-ios:
@ -46,7 +46,7 @@ jobs:
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: 'true'
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Build iOS Simulator

View file

@ -5,7 +5,7 @@ on:
- cron: "0 0 * * 4,5" # Wednesday and Thursday midnight
env:
INTUNE_ENABLED: 1
INTUNE_ENABLED: 'true'
jobs:
update-initial-status-ios:
@ -45,7 +45,7 @@ jobs:
- name: Prepare iOS Build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: 'true'
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Build iOS Simulator

View file

@ -15,7 +15,7 @@ env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
SNYK_VERSION: "1.1297.2"
CYCLONEDX_VERSION: "v0.27.2"
INTUNE_ENABLED: 1
INTUNE_ENABLED: 'true'
jobs:
test:
@ -42,7 +42,7 @@ jobs:
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
with:
intune-enabled: 'true'
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: ci/build-ios-unsigned

View file

@ -135,6 +135,42 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @gorhom/portal
This product contains '@gorhom/portal' by Mo Gorhom.
A simplified portal implementation for ⭕️ React Native ⭕️
* HOMEPAGE:
* https://github.com/gorhom/react-native-portal#readme
* LICENSE: MIT
MIT License
Copyright (c) 2020 Mo Gorhom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @mattermost/calls
@ -1702,6 +1738,43 @@ Lightweight fuzzy-search
limitations under the License.
---
## grapheme-splitter
This product contains 'grapheme-splitter' by Orlin Georgiev.
A JavaScript library that breaks strings into their individual user-perceived characters. It supports emojis!
* HOMEPAGE:
* https://github.com/orling/grapheme-splitter
* LICENSE: MIT
The MIT License (MIT)
Copyright (c) 2015 Orlin Georgiev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## html-entities
@ -2437,6 +2510,42 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-keyboard-controller
This product contains 'react-native-keyboard-controller' by Kiryl Ziusko.
Keyboard manager which works in identical way on both iOS and Android
* HOMEPAGE:
* https://kirillzyusko.github.io/react-native-keyboard-controller/
* LICENSE: MIT
MIT License
Copyright (c) 2021 Kiryl Ziusko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-keychain

View file

@ -115,8 +115,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 701
versionName "2.36.0"
versionCode 717
versionName "2.37.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -4,7 +4,7 @@
import {defineMessages} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {ActionType, General, Navigation, Screens} from '@constants';
import {ActionType, Events, General, Navigation, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getTranslations} from '@i18n';
import {getChannelById} from '@queries/servers/channel';
@ -135,6 +135,8 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
subtitle = subtitle.replace('{channelName}', channel.displayName);
}
DeviceEventEmitter.emit(Events.CLOSE_INPUT_ACCESSORY_VIEW);
goToScreen(Screens.THREAD, '', {rootId}, {
topBar: {
title: {

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Portal} from '@gorhom/portal';
import React, {useMemo, useState} from 'react';
import {Platform, type StyleProp, useWindowDimensions, type ViewStyle} from 'react-native';
import Animated, {type SharedValue, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
@ -65,6 +66,7 @@ type Props = {
autocompleteProviders?: AutocompleteProviders;
useAllAvailableSpace?: boolean;
horizontalPadding?: number;
usePortal?: boolean;
}
type AutocompleteProviders = {
@ -101,6 +103,7 @@ const Autocomplete = ({
autocompleteProviders = defaultAutocompleteProviders,
useAllAvailableSpace = false,
horizontalPadding = 8,
usePortal = false,
}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
@ -146,7 +149,7 @@ const Autocomplete = ({
return s;
}, [style.base, style.borders, style.shadow, horizontalPadding, containerAnimatedStyle, hasElements, containerStyle]);
return (
const component = (
<Animated.View
testID='autocomplete'
style={containerStyles}
@ -224,6 +227,16 @@ const Autocomplete = ({
</>)}
</Animated.View>
);
if (usePortal && Platform.OS === 'android') {
return (
<Portal>
{component}
</Portal>
);
}
return component;
};
export default Autocomplete;

View file

@ -22,6 +22,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 2,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.56),
@ -54,3 +55,4 @@ const EmojiCategoryBarIcon = ({currentIndex, icon, index, scrollToIndex, theme}:
};
export default EmojiCategoryBarIcon;

View file

@ -0,0 +1,177 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {ScrollView, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {selectEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
import {useFocusAfterEmojiDismiss} from '@hooks/useFocusAfterEmojiDismiss';
import {usePreventDoubleTap} from '@hooks/utils';
import {deleteLastGrapheme} from '@utils/grapheme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import EmojiCategoryBarIcon from './icon';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.centerChannelBg,
height: 55,
paddingHorizontal: 12,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
borderTopWidth: 1,
flexDirection: 'row',
alignItems: 'center',
},
actionButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
actionIcon: {
color: changeOpacity(theme.centerChannelColor, 0.56),
},
scrollableContainer: {
flex: 1,
marginHorizontal: 8,
},
categoriesContainer: {
flexDirection: 'row',
alignItems: 'center',
},
separator: {
width: 1,
height: 20,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
marginHorizontal: 8,
},
}));
type Props = {
onSelect?: (index: number | undefined) => void;
}
const EmojiCategoryBar = ({onSelect}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {currentIndex, icons} = useEmojiCategoryBar();
const keyboardContext = useKeyboardAnimationContext();
const {
focusInput,
inputRef,
updateValue,
updateCursorPosition,
cursorPositionRef,
} = keyboardContext;
const {focus: focusWithEmojiDismiss} = useFocusAfterEmojiDismiss(inputRef, focusInput);
// Only show keyboard/delete buttons if we're in input accessory view mode
// Check if updateValue is available (not null) to determine if we're in input accessory context
const showActionButtons = updateValue !== null;
const scrollToIndex = useCallback((index: number) => {
if (onSelect) {
onSelect(index);
return;
}
selectEmojiCategoryBarSection(index);
}, [onSelect]);
const handleKeyboardPress = usePreventDoubleTap(useCallback(() => {
focusWithEmojiDismiss();
}, [focusWithEmojiDismiss]));
const deleteCharFromCurrentCursorPosition = useCallback(() => {
if (!updateValue || !updateCursorPosition || !cursorPositionRef) {
return;
}
const currentCursorPosition = cursorPositionRef.current;
if (currentCursorPosition === 0) {
return;
}
updateValue((value: string) => {
const result = deleteLastGrapheme(value, currentCursorPosition);
cursorPositionRef.current = result.cursorPosition;
updateCursorPosition(result.cursorPosition);
return result.text;
});
}, [updateValue, updateCursorPosition, cursorPositionRef]);
if (!icons) {
return null;
}
return (
<View
style={styles.container}
testID='emoji_picker.category_bar'
>
{showActionButtons && (
<>
<TouchableWithFeedback
onPress={handleKeyboardPress}
style={styles.actionButton}
type='opacity'
testID='emoji_picker.keyboard_button'
>
<CompassIcon
name='keyboard-outline'
size={20}
style={styles.actionIcon}
/>
</TouchableWithFeedback>
<View style={styles.separator}/>
</>
)}
<ScrollView
horizontal={true}
showsHorizontalScrollIndicator={false}
style={styles.scrollableContainer}
contentContainerStyle={styles.categoriesContainer}
>
{icons.map((icon, index) => (
<EmojiCategoryBarIcon
currentIndex={currentIndex}
key={icon.key}
icon={icon.icon}
index={index}
scrollToIndex={scrollToIndex}
theme={theme}
/>
))}
</ScrollView>
{showActionButtons && (
<>
<View style={styles.separator}/>
<TouchableWithFeedback
onPress={deleteCharFromCurrentCursorPosition}
style={styles.actionButton}
type='opacity'
testID='emoji_picker.delete_button'
>
<CompassIcon
name='backspace-outline'
size={20}
style={styles.actionIcon}
/>
</TouchableWithFeedback>
</>
)}
</View>
);
};
export default EmojiCategoryBar;

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default as InputAccessoryViewContainer} from './input_accessory_view_container';
export {default as InputAccessoryViewContent} from './input_accessory_view_content';

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ReactNode} from 'react';
import Animated, {useAnimatedStyle, type SharedValue} from 'react-native-reanimated';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
children: ReactNode;
animatedHeight: SharedValue<number>;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.centerChannelBg,
overflow: 'hidden',
width: '100%',
},
}));
/**
* InputAccessoryViewContainer - Container for input accessory view content
*/
const InputAccessoryViewContainer = ({
children,
animatedHeight,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const animatedStyle = useAnimatedStyle(() => {
return {
height: animatedHeight.value,
};
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Animated.View
style={[
styles.container,
animatedStyle,
]}
>
{children}
</Animated.View>
);
};
export default InputAccessoryViewContainer;

View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import CustomEmojiPicker from '@components/post_draft/custom_emoji_picker';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
/**
* InputAccessoryViewContent - Content for input accessory view
*/
const InputAccessoryViewContent = () => {
const {inputAccessoryViewAnimatedHeight, isEmojiSearchFocused, setIsEmojiSearchFocused} = useKeyboardAnimationContext();
return (
<CustomEmojiPicker
height={inputAccessoryViewAnimatedHeight}
isEmojiSearchFocused={isEmojiSearchFocused}
setIsEmojiSearchFocused={setIsEmojiSearchFocused}
/>
);
};
export default InputAccessoryViewContent;

View file

@ -0,0 +1,584 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode} from 'react';
import {BackHandler, DeviceEventEmitter, Keyboard, Platform, type StyleProp, StyleSheet, type ViewStyle, View, type LayoutChangeEvent, type GestureResponderEvent} from 'react-native';
import {KeyboardGestureArea} from 'react-native-keyboard-controller';
import Animated, {runOnJS, useAnimatedReaction, useSharedValue, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {InputAccessoryViewContainer, InputAccessoryViewContent} from '@components/input_accessory_view';
import {Events} from '@constants';
import {KeyboardAnimationProvider} from '@context/keyboard_animation';
import {useIsTablet, useWindowDimensions} from '@hooks/device';
import {useInputAccessoryView} from '@hooks/useInputAccessoryView';
import {useKeyboardAwarePostDraft} from '@hooks/useKeyboardAwarePostDraft';
const isIOS = Platform.OS === 'ios';
const Wrapper = isIOS ? KeyboardGestureArea : View;
type RenderListProps = {
listRef: ReturnType<typeof useKeyboardAwarePostDraft>['listRef'];
contentInset: ReturnType<typeof useKeyboardAwarePostDraft>['contentInset'];
onScroll: ReturnType<typeof useKeyboardAwarePostDraft>['onScroll'];
keyboardCurrentHeight: ReturnType<typeof useKeyboardAwarePostDraft>['keyboardTranslateY'];
postInputContainerHeight: number;
onTouchMove?: (event: GestureResponderEvent) => void;
onTouchEnd?: () => void;
};
type Props = {
children: ReactNode;
renderList: (props: RenderListProps) => ReactNode;
textInputNativeID: string;
containerStyle?: StyleProp<ViewStyle>;
isThreadView?: boolean;
enabled?: boolean;
onEmojiSearchFocusChange?: (focused: boolean) => void;
};
const styles = StyleSheet.create({
gestureArea: {
justifyContent: 'flex-end',
flex: 1,
},
inputContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
});
/**
* Wrapper component that provides keyboard-aware behavior for post draft screens
* Handles keyboard animations, scroll adjustments, and input container positioning
*/
export const KeyboardAwarePostDraftContainer = ({
children,
renderList,
textInputNativeID,
containerStyle,
isThreadView = false,
enabled = true,
onEmojiSearchFocusChange,
}: Props) => {
const {height: windowHeight} = useWindowDimensions();
const insets = useSafeAreaInsets();
const isTablet = useIsTablet();
const effectiveWindowHeight = isTablet ? windowHeight : windowHeight - insets.bottom;
const {
keyboardTranslateY: keyboardCurrentHeight,
listRef,
inputRef,
contentInset: bottomInset,
onScroll,
postInputContainerHeight,
setPostInputContainerHeight,
inputContainerAnimatedStyle,
keyboardHeight,
scrollOffset,
scrollPosition,
blurInput,
focusInput,
blurAndDismissKeyboard,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
isTransitioningFromCustomView,
} = useKeyboardAwarePostDraft(isThreadView, enabled);
const {
showInputAccessoryView,
setShowInputAccessoryView,
lastKeyboardHeight,
inputAccessoryViewAnimatedHeight,
} = useInputAccessoryView({
keyboardHeight,
isKeyboardFullyOpen,
});
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
useEffect(() => {
onEmojiSearchFocusChange?.(isEmojiSearchFocused);
}, [isEmojiSearchFocused, onEmojiSearchFocusChange]);
// Ref to store cursor position from PostInput
const cursorPositionRef = useRef<number>(0);
// Function to register cursor position updates from PostInput
const registerCursorPosition = useCallback((cursorPosition: number) => {
cursorPositionRef.current = cursorPosition;
}, []);
// Refs to store PostInput callbacks
const updateValueRef = useRef<React.Dispatch<React.SetStateAction<string>> | null>(null);
const updateCursorPositionRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
// Function to register PostInput callbacks
const registerPostInputCallbacks = useCallback((
updateValueFn: React.Dispatch<React.SetStateAction<string>>,
updateCursorPositionFn: React.Dispatch<React.SetStateAction<number>>,
) => {
updateValueRef.current = updateValueFn;
updateCursorPositionRef.current = updateCursorPositionFn;
if (updateValueFn) {
updateValueFn((currentValue: string) => {
cursorPositionRef.current = currentValue.length;
return currentValue;
});
}
}, []);
// Ref to track if a layout update is already scheduled
const layoutUpdateScheduledRef = useRef(false);
const pendingHeightRef = useRef<number | null>(null);
// Helper to apply the batched height update
const applyBatchedHeightUpdate = useCallback(() => {
layoutUpdateScheduledRef.current = false;
if (pendingHeightRef.current !== null) {
const heightToSet = pendingHeightRef.current;
pendingHeightRef.current = null;
// Only update if the rounded height changed by more than 0.5px (a real change).
// This prevents jitter in FlatList paddingTop and improves performance.
setPostInputContainerHeight((prevHeight) => {
const roundedPrevHeight = Math.round(prevHeight);
if (roundedPrevHeight !== heightToSet) {
return heightToSet;
}
return prevHeight;
});
}
}, [setPostInputContainerHeight]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
const newHeight = e.nativeEvent.layout.height;
const roundedHeight = Math.round(newHeight);
// Store the latest height value
pendingHeightRef.current = roundedHeight;
// If an update is already scheduled, skip scheduling another one
// This batches all layout updates during animations to a single update per frame
if (layoutUpdateScheduledRef.current) {
return;
}
// Schedule update for next frame to batch rapid layout changes during animations
layoutUpdateScheduledRef.current = true;
requestAnimationFrame(applyBatchedHeightUpdate);
}, [applyBatchedHeightUpdate]);
// Refs for tracking emoji picker swipe-to-dismiss gesture
const previousTouchYRef = useRef<number | null>(null);
const lastDistanceFromBottomRef = useRef<number | null>(null);
const lastIsSwipingDownRef = useRef<boolean | null>(null);
const originalEmojiPickerHeightRef = useRef<number>(0);
const isGestureActiveRef = useRef<boolean>(false);
const gestureStartedInEmojiPickerRef = useRef<boolean>(false);
// Shared value to track scroll adjustment during emoji picker animation
const animatedScrollAdjustment = useSharedValue(0);
// Callback to perform scroll adjustment
const performScrollAdjustment = useCallback((targetOffset: number) => {
listRef.current?.scrollToOffset({
offset: targetOffset,
animated: false,
});
}, [listRef]);
// React to animatedScrollAdjustment changes and scroll the list accordingly
// This enables smooth scrolling as emoji picker animates
useAnimatedReaction(
() => animatedScrollAdjustment.value,
(current, previous) => {
// Only scroll if value actually changed and is valid
if (previous !== null && current !== previous && current !== 0) {
runOnJS(performScrollAdjustment)(current);
}
},
[animatedScrollAdjustment],
);
// Handle touch move: track finger position and adjust emoji picker height
const handleTouchMove = useCallback((event: GestureResponderEvent) => {
if (!showInputAccessoryView || Keyboard.isVisible()) {
return;
}
// Get finger Y position on screen
const fingerY = event.nativeEvent.pageY;
if (fingerY == null) {
return;
}
// On first touch, check if gesture started within emoji picker bounds
if (!isGestureActiveRef.current) {
const currentEmojiPickerHeight = inputAccessoryViewAnimatedHeight.value;
const emojiPickerTopEdge = effectiveWindowHeight - postInputContainerHeight - currentEmojiPickerHeight;
const emojiPickerBottomEdge = effectiveWindowHeight - postInputContainerHeight;
// Check if touch is within emoji picker area
const isTouchInEmojiPicker = fingerY >= emojiPickerTopEdge && fingerY <= emojiPickerBottomEdge;
if (!isTouchInEmojiPicker) {
return;
}
isGestureActiveRef.current = true;
gestureStartedInEmojiPickerRef.current = true;
}
// Only process if gesture started in emoji picker
if (!gestureStartedInEmojiPickerRef.current) {
return;
}
const distanceFromBottom = effectiveWindowHeight - fingerY;
// Subtract input container height to get emoji picker height
const emojiPickerHeight = distanceFromBottom - postInputContainerHeight;
const maxHeight = originalEmojiPickerHeightRef.current;
const clampedHeight = emojiPickerHeight < 0 ? 0 : Math.min(emojiPickerHeight, maxHeight);
inputAccessoryViewAnimatedHeight.value = clampedHeight;
bottomInset.value = clampedHeight;
lastDistanceFromBottomRef.current = clampedHeight;
lastIsSwipingDownRef.current = previousTouchYRef.current !== null && fingerY > previousTouchYRef.current;
previousTouchYRef.current = fingerY;
}, [showInputAccessoryView, postInputContainerHeight, inputAccessoryViewAnimatedHeight, bottomInset, effectiveWindowHeight]);
// Callback to dismiss emoji picker after animation completes
const dismissEmojiPicker = useCallback(() => {
// Reset emoji search focus when dismissing emoji picker
setIsEmojiSearchFocused(false);
setShowInputAccessoryView(false);
isInputAccessoryViewMode.value = false;
bottomInset.value = 0;
scrollOffset.value = 0;
keyboardHeight.value = 0;
}, [setShowInputAccessoryView, isInputAccessoryViewMode, bottomInset, scrollOffset, keyboardHeight, setIsEmojiSearchFocused]);
const closeInputAccessoryView = useCallback(() => {
// Reset emoji search focus when closing emoji picker
setIsEmojiSearchFocused(false);
setShowInputAccessoryView(false);
isInputAccessoryViewMode.value = false;
isTransitioningFromCustomView.value = false;
inputAccessoryViewAnimatedHeight.value = withTiming(0, {duration: 200});
bottomInset.value = withTiming(0, {duration: 200});
scrollOffset.value = withTiming(0, {duration: 200});
keyboardHeight.value = 0;
}, [inputAccessoryViewAnimatedHeight, bottomInset, scrollOffset, keyboardHeight, setShowInputAccessoryView, isInputAccessoryViewMode, isTransitioningFromCustomView, setIsEmojiSearchFocused]);
const scrollToEnd = useCallback(() => {
const activeHeight = Math.max(keyboardHeight.value, inputAccessoryViewAnimatedHeight.value);
const targetOffset = -activeHeight;
listRef.current?.scrollToOffset({offset: targetOffset, animated: true});
}, [listRef, keyboardHeight, inputAccessoryViewAnimatedHeight]);
useEffect(() => {
if (Platform.OS !== 'android') {
return undefined;
}
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showInputAccessoryView) {
closeInputAccessoryView();
return true;
}
return false;
});
return () => backHandler.remove();
}, [showInputAccessoryView, closeInputAccessoryView]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.CLOSE_INPUT_ACCESSORY_VIEW, () => {
closeInputAccessoryView();
});
return () => listener.remove();
}, [closeInputAccessoryView]);
// Handle touch end: decide whether to collapse or expand emoji picker
const handleTouchEnd = useCallback(() => {
isGestureActiveRef.current = false;
// Only process if gesture started in emoji picker
if (!gestureStartedInEmojiPickerRef.current) {
return;
}
if (lastDistanceFromBottomRef.current !== null && lastIsSwipingDownRef.current !== null) {
const currentInsetHeight = lastDistanceFromBottomRef.current;
const currentScrollValue = scrollPosition.value;
if (lastIsSwipingDownRef.current) {
// User was swiping DOWN → Collapse and dismiss emoji picker
// Calculate scroll positions: as bottomInset decreases from current to 0,
// list should scroll from current position to final position
const startScrollOffset = -currentInsetHeight + currentScrollValue;
const endScrollOffset = currentScrollValue;
// Animate emoji picker height to 0
inputAccessoryViewAnimatedHeight.value = withTiming(
0,
{duration: 250},
() => {
runOnJS(dismissEmojiPicker)();
},
);
bottomInset.value = withTiming(0, {duration: 250});
// Animate scroll position from start to end - this makes list scroll down smoothly
animatedScrollAdjustment.value = startScrollOffset;
animatedScrollAdjustment.value = withTiming(endScrollOffset, {
duration: 250,
}, () => {
animatedScrollAdjustment.value = 0;
});
} else {
// User was swiping UP → Expand to full height
const targetHeight = originalEmojiPickerHeightRef.current;
// Calculate scroll positions: as bottomInset increases from current to targetHeight,
// list should scroll from current position to final position
const startScrollOffset = -currentInsetHeight + currentScrollValue;
const endScrollOffset = -targetHeight + currentScrollValue;
inputAccessoryViewAnimatedHeight.value = withTiming(targetHeight, {duration: 250});
bottomInset.value = withTiming(targetHeight, {duration: 250});
// Animate scroll position from start to end - this makes list scroll up smoothly
animatedScrollAdjustment.value = startScrollOffset;
animatedScrollAdjustment.value = withTiming(endScrollOffset, {
duration: 250,
}, () => {
animatedScrollAdjustment.value = 0;
});
}
}
previousTouchYRef.current = null;
lastDistanceFromBottomRef.current = null;
lastIsSwipingDownRef.current = null;
gestureStartedInEmojiPickerRef.current = false;
}, [inputAccessoryViewAnimatedHeight, dismissEmojiPicker, bottomInset, scrollPosition, animatedScrollAdjustment]);
useLayoutEffect(() => {
if (showInputAccessoryView && Platform.OS === 'ios') {
keyboardHeight.value = 0;
}
}, [showInputAccessoryView, keyboardHeight]);
// After emoji picker renders, adjust heights and scroll to keep messages visible
useEffect(() => {
if (showInputAccessoryView) {
// Wait one frame to ensure emoji picker has rendered
requestAnimationFrame(() => {
const emojiPickerHeight = inputAccessoryViewAnimatedHeight.value;
const currentScroll = scrollPosition.value;
originalScrollBeforeEmojiPicker.value = currentScroll;
originalEmojiPickerHeightRef.current = emojiPickerHeight;
// For inverted list: when bottomInset increases, content shifts UP visually. Scroll UP to compensate.
const targetContentOffset = currentScroll - emojiPickerHeight;
bottomInset.value = emojiPickerHeight;
scrollOffset.value = emojiPickerHeight;
listRef.current?.scrollToOffset({
offset: targetContentOffset,
animated: false,
});
});
}
// Only depend on showInputAccessoryView - the effect should only run when emoji picker visibility changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showInputAccessoryView]);
// Track if we've already restored scroll for current emoji picker closing (Android only)
// Use SharedValue instead of ref so it can be accessed in worklets
const hasRestoredScrollForEmojiPicker = useSharedValue(false);
// Store the original scroll value when emoji picker opens, so we can restore it when closing
const originalScrollBeforeEmojiPicker = useSharedValue(0);
// Reset restoration flag when emoji picker opens
useEffect(() => {
if (showInputAccessoryView) {
hasRestoredScrollForEmojiPicker.value = false;
originalScrollBeforeEmojiPicker.value = 0;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showInputAccessoryView]);
// Callback to restore scroll when emoji picker closes (called from worklet)
const restoreScrollAfterEmojiPickerClose = useCallback((previousHeight: number, currentScroll: number) => {
if (listRef.current && previousHeight > 0) {
listRef.current.scrollToOffset({
offset: currentScroll,
animated: false,
});
}
// ref is not required to be in deps because it is a stable reference
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Android: Watch for emoji picker closing and restore scroll position when both height and bottomInset reach 0
const isAndroid = Platform.OS === 'android';
useAnimatedReaction(
() => ({
height: inputAccessoryViewAnimatedHeight.value,
bottomInset: bottomInset.value,
}),
(current, previous) => {
if (!isAndroid) {
return;
}
// When emoji picker closes: height goes to 0 AND bottomInset reaches 0. Check previous.bottomInset > 0 because bottomInset affects scroll.
const shouldRestoreScroll = previous !== null &&
previous.bottomInset !== undefined &&
previous.bottomInset > 0 &&
current.height === 0 &&
current.bottomInset === 0 &&
!hasRestoredScrollForEmojiPicker.value;
if (shouldRestoreScroll) {
hasRestoredScrollForEmojiPicker.value = true;
const currentScroll = scrollPosition.value;
const emojiPickerHeight = previous.bottomInset;
runOnJS(restoreScrollAfterEmojiPickerClose)(emojiPickerHeight, currentScroll);
}
},
[inputAccessoryViewAnimatedHeight, bottomInset, scrollPosition, restoreScrollAfterEmojiPickerClose],
);
const keyboardAnimationValues = useMemo(() => ({
keyboardTranslateY: keyboardCurrentHeight,
bottomInset,
scrollOffset,
keyboardHeight,
scrollPosition,
onScroll,
postInputContainerHeight,
inputRef,
blurInput,
focusInput,
blurAndDismissKeyboard,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
showInputAccessoryView,
setShowInputAccessoryView,
lastKeyboardHeight,
inputAccessoryViewAnimatedHeight,
isTransitioningFromCustomView,
closeInputAccessoryView,
scrollToEnd,
isEmojiSearchFocused,
setIsEmojiSearchFocused,
cursorPositionRef,
registerCursorPosition,
updateValue: updateValueRef.current,
updateCursorPosition: updateCursorPositionRef.current,
registerPostInputCallbacks,
// Shared values don't need to be in dependencies - they're stable references
// Only include non-shared-value dependencies that can actually change
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [
onScroll,
postInputContainerHeight,
inputRef,
blurInput,
focusInput,
blurAndDismissKeyboard,
showInputAccessoryView,
setShowInputAccessoryView,
lastKeyboardHeight,
closeInputAccessoryView,
scrollToEnd,
isEmojiSearchFocused,
setIsEmojiSearchFocused,
registerCursorPosition,
registerPostInputCallbacks,
]);
const wrapperProps = useMemo(() => {
if (isIOS) {
return {
textInputNativeID,
offset: postInputContainerHeight,
style: styles.gestureArea,
};
}
return {style: styles.gestureArea};
}, [textInputNativeID, postInputContainerHeight]);
// On iOS, use KeyboardGestureArea for interactive keyboard dismissal
// On Android, KeyboardGestureArea is Android 11+ only, but we want native behavior
// So we conditionally use it only on iOS
// KeyboardGestureArea will be a no-op on Android if rendered, but we avoid it for clarity
const content = (
<>
<View style={containerStyle}>
{renderList({
keyboardCurrentHeight,
listRef,
contentInset: bottomInset,
onScroll,
postInputContainerHeight,
onTouchMove: handleTouchMove,
onTouchEnd: handleTouchEnd,
})}
</View>
<Animated.View
style={[
inputContainerAnimatedStyle,
styles.inputContainer,
]}
>
<View onLayout={onLayout}>
{children}
</View>
{showInputAccessoryView && (
<InputAccessoryViewContainer
animatedHeight={inputAccessoryViewAnimatedHeight}
>
<InputAccessoryViewContent/>
</InputAccessoryViewContainer>
)}
</Animated.View>
</>
);
return (
<KeyboardAnimationProvider value={keyboardAnimationValues}>
<Wrapper {...wrapperProps}>
{content}
</Wrapper>
</KeyboardAnimationProvider>
);
};

View file

@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Fuse from 'fuse.js';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, Platform, Text, View, type ListRenderItemInfo} from 'react-native';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {getEmojis, searchEmojis} from '@utils/emoji/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import EmojiItem from './emoji_item';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
type Props = {
customEmojis: CustomEmojiModel[];
skinTone: string;
searchTerm: string;
onEmojiPress: (emojiName: string) => void;
hideEmojiNames?: boolean;
};
export const SEARCH_BAR_HEIGHT = 56; // paddingTop: 12 + paddingBottom: 12 + search bar ~32px
export const SEARCH_CONTAINER_PADDING = 8; // paddingVertical: 4 * 2
export const SEARCH_VISIBILITY_OFFSET = 40; // Extra height to ensure search values are visible
export const SEARCH_VISIBILITY_OFFSET_ANDROID = 60;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
paddingVertical: 4,
paddingHorizontal: 12,
},
listContainer: {
flexGrow: 1,
justifyContent: 'center',
},
noResultContainer: {
height: 40,
margin: 10,
},
noResultText: {
color: theme.centerChannelColor,
opacity: 0.72,
},
};
});
const EmojiFiltered: React.FC<Props> = ({
customEmojis,
skinTone,
searchTerm = '',
onEmojiPress,
hideEmojiNames = false,
}) => {
const {keyboardHeight} = useKeyboardAnimationContext();
const emojis = useMemo(() => getEmojis(skinTone, customEmojis), [skinTone, customEmojis]);
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
// Calculate dynamic height: keyboardHeight + search bar height + search container padding + visibility offset
// On Android, use increased visibility offset to account for bottom safe area
const animatedStyle = useAnimatedStyle(() => {
const calculatedKeyboardHeight = Math.max(keyboardHeight.value, 0);
const visibilityOffset = Platform.OS === 'android' ? SEARCH_VISIBILITY_OFFSET_ANDROID : SEARCH_VISIBILITY_OFFSET;
const calculatedHeight = calculatedKeyboardHeight + SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset;
return {
height: calculatedHeight,
};
}, [keyboardHeight]);
const fuse = useMemo(() => {
const options = {findAllMatches: true, ignoreLocation: true, includeMatches: true, shouldSort: false, includeScore: true};
return new Fuse(emojis, options);
}, [emojis]);
const data = useMemo(() => {
if (!searchTerm) {
return emojis;
}
return searchEmojis(fuse, searchTerm);
}, [emojis, fuse, searchTerm]);
const keyExtractor = (item: string) => item;
const renderEmpty = useCallback(() => {
return (
<View style={style.noResultContainer}>
<Text style={style.noResultText}>
{intl.formatMessage({
id: 'custom_emoji_picker.search.no_results',
defaultMessage: 'No results',
})}
</Text>
</View>
);
}, [style, intl]);
const renderItem = useCallback(({item}: ListRenderItemInfo<string>) => {
return (
<EmojiItem
onEmojiPress={onEmojiPress}
name={item}
hideName={hideEmojiNames}
shouldDismissKeyboard={false}
/>
);
}, [
onEmojiPress,
hideEmojiNames,
]);
return (
<Animated.View style={[style.container, animatedStyle]}>
<FlatList
data={data}
initialNumToRender={30}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='always'
keyExtractor={keyExtractor}
ListEmptyComponent={renderEmpty}
renderItem={renderItem}
removeClippedSubviews={false}
horizontal={true}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={data.length === 0 && style.listContainer}
/>
</Animated.View>
);
};
export default EmojiFiltered;

View file

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback} from 'react';
import {Keyboard, Text, TouchableOpacity, View} from 'react-native';
import Emoji from '@components/emoji';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
type TouchableEmojiProps = {
name: string;
onEmojiPress: (emojiName: string) => void;
hideName?: boolean;
shouldDismissKeyboard?: boolean;
}
const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
height: 40,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
overflow: 'hidden',
},
emojiContainer: {
marginRight: 5,
},
emoji: {
color: '#000',
},
emojiText: {
fontSize: 13,
color: theme.centerChannelColor,
},
};
});
const EmojiTouchable = ({name, onEmojiPress, hideName = false, shouldDismissKeyboard = true}: TouchableEmojiProps) => {
const theme = useTheme();
const style = getStyleSheetFromTheme(theme);
const onPress = useCallback(() => {
if (shouldDismissKeyboard && Keyboard.isVisible()) {
Keyboard.dismiss();
}
onEmojiPress(name);
}, [name, onEmojiPress, shouldDismissKeyboard]);
return (
<TouchableOpacity
onPress={onPress}
style={style.container}
>
<View style={style.emojiContainer}>
<Emoji
emojiName={name}
textStyle={style.emoji}
size={30}
/>
</View>
{!hideName && <Text style={style.emojiText}>{`:${name}:`}</Text>}
</TouchableOpacity>
);
};
export default memo(EmojiTouchable);

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {queryEmojiPreferences} from '@queries/servers/preference';
import EmojiFiltered from './emoji_filtered';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
skinTone: queryEmojiPreferences(database, Preferences.EMOJI_SKINTONE).
observeWithColumns(['value']).pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(EmojiFiltered));

View file

@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import {searchCustomEmojis} from '@actions/remote/custom_emoji';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {getKeyboardAppearanceFromTheme} from '@utils/theme';
import EmojiFiltered from '../emoji_filtered';
import EmojiPickerHeader from '../emoji_picker_header';
import CustomEmojiSections from '../emoji_sections';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type {SharedValue} from 'react-native-reanimated';
type Props = {
customEmojis: CustomEmojiModel[];
customEmojisEnabled: boolean;
onEmojiPress: (emoji: string) => void;
imageUrl?: string;
file?: ExtractedFileInfo;
recentEmojis: string[];
testID?: string;
isEmojiSearchFocused: boolean;
setIsEmojiSearchFocused: React.Dispatch<React.SetStateAction<boolean>>;
emojiPickerHeight: SharedValue<number>;
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
const EmojiPicker: React.FC<Props> = ({
customEmojisEnabled,
imageUrl,
file,
recentEmojis,
customEmojis,
onEmojiPress,
testID = '',
isEmojiSearchFocused,
setIsEmojiSearchFocused,
emojiPickerHeight,
}) => {
const theme = useTheme();
const serverUrl = useServerUrl();
const [searchTerm, setSearchTerm] = useState<string|undefined>();
const isSelectingEmoji = useRef(false);
const onCancelSearch = useCallback(() => {
setSearchTerm(undefined);
setIsEmojiSearchFocused(false);
isSelectingEmoji.current = false;
}, [setIsEmojiSearchFocused]);
const searchCustom = debounce((text: string) => {
if (text && text.length > 1) {
searchCustomEmojis(serverUrl, text);
}
}, 500);
const onChangeSearchTerm = useCallback((text: string) => {
setSearchTerm(text);
searchCustom(text.replace(/^:|:$/g, '').trim());
}, [searchCustom]);
const term = searchTerm?.replace(/^:|:$/g, '').trim() || '';
// Wrap onEmojiPress to set flag before emoji selection
const handleEmojiPress = useCallback((emojiName: string) => {
isSelectingEmoji.current = true;
onEmojiPress(emojiName);
}, [onEmojiPress]);
const EmojiList = (
<EmojiFiltered
customEmojis={customEmojis}
searchTerm={term}
onEmojiPress={handleEmojiPress}
hideEmojiNames={isEmojiSearchFocused}
/>
);
const EmojiSection = (
<CustomEmojiSections
customEmojis={customEmojis}
customEmojisEnabled={customEmojisEnabled}
imageUrl={imageUrl}
file={file}
onEmojiPress={onEmojiPress}
recentEmojis={recentEmojis}
/>
);
return (
<View
style={styles.flex}
testID='emoji_picker'
>
<EmojiPickerHeader
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
onCancel={onCancelSearch}
onChangeText={onChangeSearchTerm}
testID={`${testID}.search_bar`}
value={searchTerm}
setIsEmojiSearchFocused={setIsEmojiSearchFocused}
emojiPickerHeight={emojiPickerHeight}
isSelectingEmojiRef={isSelectingEmoji}
/>
{!isEmojiSearchFocused && EmojiSection}
{isEmojiSearchFocused && EmojiList}
</View>
);
};
export default EmojiPicker;

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observeConfigBooleanValue, observeRecentReactions} from '@queries/servers/system';
import EmojiPicker from './emoji_picker';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
customEmojisEnabled: observeConfigBooleanValue(database, 'EnableCustomEmoji'),
customEmojis: queryAllCustomEmojis(database).observe(),
recentEmojis: observeRecentReactions(database),
}));
export default withDatabase(enhanced(EmojiPicker));

View file

@ -0,0 +1,241 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {DeviceEventEmitter, Platform, View, type LayoutChangeEvent} from 'react-native';
import {useKeyboardState} from 'react-native-keyboard-controller';
import {Easing, useAnimatedReaction, useSharedValue, withTiming, type SharedValue} from 'react-native-reanimated';
import SearchBar, {type SearchProps, type SearchRef} from '@components/search';
import {Events} from '@constants';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {setEmojiSkinTone} from '@hooks/emoji_category_bar';
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
import SkinToneSelector from '@screens/emoji_picker/picker/header/skintone_selector';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const SEARCH_BAR_HEIGHT = 56; // paddingTop: 12 + paddingBottom: 12 + search bar ~32px
const SEARCH_CONTAINER_PADDING = 8; // paddingVertical: 4 * 2
const SEARCH_VISIBILITY_OFFSET = 40; // Extra height to ensure search values are visible
const SEARCH_VISIBILITY_OFFSET_ANDROID = 60;
type Props = SearchProps & {
skinTone: string;
setIsEmojiSearchFocused: React.Dispatch<React.SetStateAction<boolean>>;
emojiPickerHeight: SharedValue<number>;
isSelectingEmojiRef?: React.MutableRefObject<boolean>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
flex: {flex: 1},
row: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingTop: 12,
paddingBottom: 12,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08),
backgroundColor: theme.centerChannelBg,
},
}));
const EmojiPickerHeader: React.FC<Props> = ({
skinTone,
emojiPickerHeight,
setIsEmojiSearchFocused,
isSelectingEmojiRef,
...props
}) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {lastKeyboardHeight: contextLastKeyboardHeight, showInputAccessoryView, isInputAccessoryViewMode} = useKeyboardAnimationContext();
const containerWidth = useSharedValue(0);
const isSearching = useSharedValue(false);
const [showKeyboard, setShowKeyboard] = useState(false);
const keyboardTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isReducingHeight = useRef(false);
const searchRef = useRef<SearchRef>(null);
// Use useKeyboardState to get keyboard height
const {height: keyboardHeight} = useKeyboardState();
const keyboardHeightShared = useSharedValue(keyboardHeight);
useEffect(() => {
const req = requestAnimationFrame(() => {
setEmojiSkinTone(skinTone);
});
return () => cancelAnimationFrame(req);
}, [skinTone]);
// Update SharedValue when keyboard height changes
useEffect(() => {
keyboardHeightShared.value = keyboardHeight;
}, [keyboardHeight, keyboardHeightShared]);
// Reset search state when emoji picker is dismissed to prevent reaction from firing
useEffect(() => {
if (!showInputAccessoryView) {
isSearching.value = false;
setIsEmojiSearchFocused(false);
setShowKeyboard(false);
isReducingHeight.current = false;
if (keyboardTimeoutRef.current) {
clearTimeout(keyboardTimeoutRef.current);
keyboardTimeoutRef.current = null;
}
}
}, [showInputAccessoryView, isSearching, setIsEmojiSearchFocused]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (keyboardTimeoutRef.current) {
clearTimeout(keyboardTimeoutRef.current);
}
};
}, []);
// Update emoji picker height when keyboard height changes and search is focused
useAnimatedReaction(
() => keyboardHeightShared.value,
(currentKeyboardH, previousKeyboardH) => {
// Only update if:
// 1. Search is focused
// 2. Emoji picker is still showing (not being dismissed)
// 3. Keyboard height actually changed
if (
isSearching.value &&
isInputAccessoryViewMode.value &&
previousKeyboardH !== undefined &&
previousKeyboardH !== currentKeyboardH
) {
// Skip if keyboard height is 0 (onFocus handler already set correct height using lastKeyboardHeight)
if (currentKeyboardH === 0) {
return;
}
// Calculate total height: keyboardHeight + search bar height + search container padding + visibility offset
const visibilityOffset = Platform.OS === 'android' ? SEARCH_VISIBILITY_OFFSET_ANDROID : SEARCH_VISIBILITY_OFFSET;
const targetHeight = Platform.OS === 'android' ? SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset : currentKeyboardH + SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset;
emojiPickerHeight.value = withTiming(targetHeight, {
duration: 250,
easing: Easing.out(Easing.ease),
});
}
},
[keyboardHeightShared, isSearching, emojiPickerHeight, isInputAccessoryViewMode],
);
const onBlur = useCallback(() => {
// Ignore blur events during height reduction process
if (isReducingHeight.current) {
return;
}
if (isSelectingEmojiRef?.current) {
isSelectingEmojiRef.current = false;
return;
}
setIsEmojiSearchFocused(false);
isSearching.value = false;
setShowKeyboard(false);
DeviceEventEmitter.emit(Events.EMOJI_PICKER_SEARCH_FOCUSED, false);
// Clear any pending keyboard timeout
if (keyboardTimeoutRef.current) {
clearTimeout(keyboardTimeoutRef.current);
keyboardTimeoutRef.current = null;
}
// Only restore height if emoji picker is still showing
// If emoji picker is being dismissed (showInputAccessoryView is false),
// PostInput.onFocus already handles setting height to 0
if (showInputAccessoryView) {
// Restore to original emoji picker height (without search-related additions)
const originalHeight = contextLastKeyboardHeight > 0 ? contextLastKeyboardHeight : DEFAULT_INPUT_ACCESSORY_HEIGHT;
emojiPickerHeight.value = withTiming(originalHeight, {duration: 250});
}
}, [emojiPickerHeight, isSearching, setIsEmojiSearchFocused, contextLastKeyboardHeight, showInputAccessoryView, isSelectingEmojiRef]);
const onFocus = useCallback(() => {
if (Platform.OS === 'android' && showKeyboard) {
isReducingHeight.current = false;
return;
}
setIsEmojiSearchFocused(true);
DeviceEventEmitter.emit(Events.EMOJI_PICKER_SEARCH_FOCUSED, true);
// Use last keyboard height from context if available, otherwise use default input accessory height
// The useAnimatedReaction will handle real-time updates when keyboard actually opens
const keyboardH = contextLastKeyboardHeight > 0 ? contextLastKeyboardHeight : DEFAULT_INPUT_ACCESSORY_HEIGHT;
const visibilityOffset = Platform.OS === 'android' ? SEARCH_VISIBILITY_OFFSET_ANDROID : SEARCH_VISIBILITY_OFFSET;
const targetHeight = Platform.OS === 'android' ? SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset : keyboardH + SEARCH_BAR_HEIGHT + SEARCH_CONTAINER_PADDING + visibilityOffset;
if (Platform.OS === 'android') {
setShowKeyboard(false);
isReducingHeight.current = true;
}
// Set height immediately (without animation) to prevent jump
emojiPickerHeight.value = withTiming(targetHeight, {duration: 250});
isSearching.value = true;
// On Android, delay keyboard opening to allow height reduction to render first
if (Platform.OS === 'android') {
if (keyboardTimeoutRef.current) {
clearTimeout(keyboardTimeoutRef.current);
}
// Blur immediately to prevent keyboard from opening
searchRef.current?.blur();
const handleDelayedFocus = () => {
setShowKeyboard(true);
requestAnimationFrame(() => {
searchRef.current?.focus();
});
keyboardTimeoutRef.current = null;
};
keyboardTimeoutRef.current = setTimeout(handleDelayedFocus, 500);
}
}, [emojiPickerHeight, isSearching, setIsEmojiSearchFocused, contextLastKeyboardHeight, showKeyboard]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
containerWidth.value = e.nativeEvent.layout.width;
}, [containerWidth]);
return (
<View
onLayout={onLayout}
style={styles.row}
>
<View style={styles.flex}>
<SearchBar
{...props}
onBlur={onBlur}
onFocus={onFocus}
ref={searchRef}
showSoftInputOnFocus={Platform.OS !== 'android' || showKeyboard}
/>
</View>
<SkinToneSelector
skinTone={skinTone}
containerWidth={containerWidth}
isSearching={isSearching}
/>
</View>
);
};
export default EmojiPickerHeader;

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {queryEmojiPreferences} from '@queries/servers/preference';
import EmojiPickerHeader from './emoji_picker_header';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
skinTone: queryEmojiPreferences(database, Preferences.EMOJI_SKINTONE).
observeWithColumns(['value']).pipe(
switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')),
),
}));
export default withDatabase(enhanced(EmojiPickerHeader));

View file

@ -0,0 +1,263 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {FlashList, type ListRenderItemInfo} from '@shopify/flash-list';
import {chunk} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View, StyleSheet} from 'react-native';
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
import EmojiCategoryBar from '@components/emoji_category_bar';
import {EMOJI_CATEGORY_ICONS, EMOJI_ROW_MARGIN, EMOJI_SIZE, EMOJIS_PER_PAGE, EMOJIS_PER_ROW, EMOJIS_PER_ROW_TABLET} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import {setEmojiCategoryBarIcons, setEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
import EmojiRow, {type EmojiSectionRow} from '@screens/emoji_picker/picker/sections/emoji_row';
import SectionFooter from '@screens/emoji_picker/picker/sections/section_footer';
import SectionHeader, {type EmojiSection} from '@screens/emoji_picker/picker/sections/section_header';
import {CategoryNames, EmojiIndicesByCategory, CategoryTranslations, CategoryMessage} from '@utils/emoji';
import {fillEmoji} from '@utils/emoji/helpers';
import type {CustomEmojiModel} from '@database/models/server';
type SectionListItem = EmojiSection | EmojiSectionRow;
const categoryToI18n: Record<string, CategoryTranslation> = {};
const emptyEmoji: EmojiAlias = {
name: '',
short_name: '',
aliases: [],
};
const keyExtractor = (item: SectionListItem) => {
return (item.type === 'section' ? `${item.key}` : `${item.sectionIndex}-${item.index}-${item.category}`);
};
const getItemType = (item: SectionListItem) => item.type;
const styles = StyleSheet.create({
container: {flex: 1},
containerStyle: {paddingBottom: 50, paddingHorizontal: 12},
});
type Props = {
customEmojis: CustomEmojiModel[];
customEmojisEnabled: boolean;
imageUrl?: string;
file?: ExtractedFileInfo;
onEmojiPress: (emoji: string) => void;
recentEmojis: string[];
};
CategoryNames.forEach((name: string) => {
if (CategoryTranslations.has(name) && CategoryMessage.has(name)) {
categoryToI18n[name] = {
id: CategoryTranslations.get(name)!,
defaultMessage: CategoryMessage.get(name)!,
icon: EMOJI_CATEGORY_ICONS[name],
};
}
});
export default function CustomEmojiSections({customEmojis, customEmojisEnabled, file, imageUrl, onEmojiPress, recentEmojis}: Props) {
const [customEmojiPage, setCustomEmojiPage] = useState(() => Math.ceil(customEmojis.length / EMOJIS_PER_PAGE));
const [fetchingCustomEmojis, setFetchingCustomEmojis] = useState(false);
const [loadedAllCustomEmojis, setLoadedAllCustomEmojis] = useState(false);
const scrollingToIndex = useRef(false);
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const {currentIndex, selectedIndex} = useEmojiCategoryBar();
const list = useRef<FlashList<SectionListItem> | null>(null);
const sections: SectionListItem[] = useMemo(() => {
const emojisPerRow = isTablet ? EMOJIS_PER_ROW_TABLET : EMOJIS_PER_ROW;
const sectionsArray = CategoryNames.map<EmojiSection>((category) => {
return {
type: 'section',
...categoryToI18n[category],
key: category,
};
});
if (imageUrl || file) {
sectionsArray.unshift({
type: 'section',
id: 'emoji_picker.default',
defaultMessage: 'Default',
icon: 'bookmark-outline',
key: 'default',
});
}
return sectionsArray.reduce<SectionListItem[]>((acc, section, sectionIndex) => {
acc.push(section);
const emojiIndices = EmojiIndicesByCategory.get('default')?.get(section.key);
let emojiArray: EmojiAlias[][];
switch (section.key) {
case 'custom': {
const builtInCustom = emojiIndices.map(fillEmoji.bind(null, 'custom'));
const mapCustom = (ce: CustomEmojiModel) => ({
aliases: [],
name: ce.name,
short_name: '',
});
const custom = customEmojisEnabled ? customEmojis.map(mapCustom) : [];
emojiArray = chunk<EmojiAlias>(builtInCustom.concat(custom), emojisPerRow);
break;
}
case 'recent': {
const recentMap = (emoji: string) => ({
aliases: [],
name: emoji,
short_name: '',
});
if (recentEmojis.length === 0) {
acc.pop();
return acc;
}
emojiArray = chunk<EmojiAlias>(recentEmojis.map(recentMap), emojisPerRow);
break;
}
case 'default':
acc.push({
type: 'row',
emojis: [{
aliases: [],
name: imageUrl || file?.name || '',
short_name: imageUrl || file?.name || '',
category: 'image',
}],
sectionIndex,
category: section.key,
index: 0,
});
return acc;
default:
emojiArray = chunk(emojiIndices.map(fillEmoji.bind(null, section.key)), emojisPerRow);
break;
}
for (let index = 0; index < emojiArray.length; index++) {
const d = emojiArray[index];
const emojis = d.length < emojisPerRow ? d.concat(new Array(emojisPerRow - d.length).fill(emptyEmoji)) : d;
acc.push({
type: 'row',
emojis,
sectionIndex,
category: section.key,
index,
});
}
return acc;
}, []);
}, [customEmojis, customEmojisEnabled, file, imageUrl, isTablet, recentEmojis]);
const stickyHeaderIndices = useMemo(() =>
sections.
map((item, index) => (item.type === 'section' ? index : undefined)).
filter((item) => item !== undefined) as number[],
[sections]);
const renderItem = useCallback(({item}: ListRenderItemInfo<SectionListItem>) => {
if (item.type === 'section') {
return (
<SectionHeader
key={item.key}
section={item}
/>
);
}
return (
<EmojiRow
key={`${item.sectionIndex}-${item.index}-${item.category}`}
emojis={item.emojis}
file={file}
imageUrl={imageUrl}
onEmojiPress={onEmojiPress}
/>
);
}, [file, imageUrl, onEmojiPress]);
const scrollToIndex = useCallback((index: number) => {
scrollingToIndex.current = true;
list.current?.scrollToIndex({animated: false, index: stickyHeaderIndices[index], viewOffset: 0});
setEmojiCategoryBarSection(index);
setTimeout(() => {
scrollingToIndex.current = false;
}, 250);
}, [stickyHeaderIndices]);
const loadMoreCustomEmojis = useCallback(async () => {
if (!customEmojisEnabled || fetchingCustomEmojis || loadedAllCustomEmojis) {
return;
}
setFetchingCustomEmojis(true);
const {data, error} = await fetchCustomEmojis(serverUrl, customEmojiPage, EMOJIS_PER_PAGE);
if (data?.length) {
setCustomEmojiPage(customEmojiPage + 1);
} else if (!error && (data && data.length < EMOJIS_PER_PAGE)) {
setLoadedAllCustomEmojis(true);
}
setFetchingCustomEmojis(false);
}, [customEmojisEnabled, fetchingCustomEmojis, loadedAllCustomEmojis, serverUrl, customEmojiPage]);
const handleStickyHeaderIndexChanged = useCallback((index: number) => {
if (scrollingToIndex.current) {
return;
}
const stickyIndex = stickyHeaderIndices.indexOf(index);
if (stickyIndex !== -1 && currentIndex !== stickyIndex) {
requestAnimationFrame(() => {
setEmojiCategoryBarSection(stickyIndex);
});
}
}, [currentIndex, stickyHeaderIndices]);
const renderFooter = useMemo(() => {
return fetchingCustomEmojis ? <SectionFooter/> : null;
}, [fetchingCustomEmojis]);
useEffect(() => {
setEmojiCategoryBarIcons(sections.filter((s) => s.type === 'section').map((s) => ({
key: s.key,
icon: s.icon,
})));
}, [sections]);
useEffect(() => {
if (selectedIndex != null) {
scrollToIndex(selectedIndex);
}
// do not include scrollToIndex in dependencies
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
return (
<View style={styles.container}>
<FlashList
contentContainerStyle={styles.containerStyle}
data={sections}
estimatedItemSize={EMOJI_SIZE + EMOJI_ROW_MARGIN}
getItemType={getItemType}
keyExtractor={keyExtractor}
ListFooterComponent={renderFooter}
onEndReachedThreshold={0.5}
onEndReached={loadMoreCustomEmojis}
onStickyHeaderIndexChanged={handleStickyHeaderIndexChanged}
ref={list}
renderItem={renderItem}
stickyHeaderIndices={stickyHeaderIndices}
/>
<EmojiCategoryBar/>
</View>
);
}

View file

@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {type SharedValue} from 'react-native-reanimated';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {EmojiIndicesByAlias, Emojis} from '@utils/emoji';
import EmojiPicker from './emoji_picker';
type Props = {
height: SharedValue<number>;
onEmojiPress?: (emoji: string) => void;
setIsEmojiSearchFocused: React.Dispatch<React.SetStateAction<boolean>>;
isEmojiSearchFocused: boolean;
}
const CustomEmojiPicker: React.FC<Props> = ({
height,
isEmojiSearchFocused,
onEmojiPress,
setIsEmojiSearchFocused,
}) => {
const {cursorPositionRef, updateValue, updateCursorPosition} = useKeyboardAnimationContext();
const handleEmojiPress = useCallback((emojiName: string) => {
// If onEmojiPress prop is provided, use it (for other use cases)
if (onEmojiPress) {
onEmojiPress(emojiName);
return;
}
// Otherwise, use context-based insertion (for input accessory view)
if (!updateValue || !updateCursorPosition || !cursorPositionRef) {
return;
}
const name = emojiName.trim();
// Calculate the inserted text first to determine its length
let insertedText = '';
if (EmojiIndicesByAlias.get(name)) {
const emoji = Emojis[EmojiIndicesByAlias.get(name)!];
if (emoji.category === 'custom') {
insertedText = ` :${emojiName}: `;
} else {
const unicode = emoji.image;
if (unicode) {
const codeArray = unicode.split('-');
const convertToUnicode = (acc: string, c: string) => {
return acc + String.fromCodePoint(parseInt(c, 16));
};
insertedText = codeArray.reduce(convertToUnicode, '');
} else {
insertedText = ` :${emojiName}: `;
}
}
} else {
insertedText = ` :${emojiName}: `;
}
// Read cursor position and calculate new position immediately
// This ensures rapid clicks use the correct position
const currentCursorPosition = cursorPositionRef.current;
const insertedTextLength = insertedText.length;
const newCursorPosition = currentCursorPosition + insertedTextLength;
// Update cursorPositionRef IMMEDIATELY (before React processes the update)
// This ensures the next rapid click uses the updated position
cursorPositionRef.current = newCursorPosition;
// Update cursor position state (for Android and to keep state in sync)
updateCursorPosition(newCursorPosition);
const insertEmoji = (v: string): string => {
// Use the captured cursor position from when the function was created
return v.slice(0, currentCursorPosition) + insertedText + v.slice(currentCursorPosition);
};
updateValue(insertEmoji);
}, [onEmojiPress, updateValue, updateCursorPosition, cursorPositionRef]);
return (
<EmojiPicker
onEmojiPress={handleEmojiPress}
testID='custom_emoji_picker'
setIsEmojiSearchFocused={setIsEmojiSearchFocused}
isEmojiSearchFocused={isEmojiSearchFocused}
emojiPickerHeight={height}
/>
);
};
export default CustomEmojiPicker;

View file

@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {Screens} from '@constants';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
@ -23,8 +24,6 @@ import Uploads from '../uploads';
import Header from './header';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
export type Props = {
testID?: string;
channelId: string;
@ -60,9 +59,8 @@ export type Props = {
scheduledPostsEnabled: boolean;
}
const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
const SCHEDULED_POST_PICKER_BUTTON = 'close-scheduled-post-picker';
const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
@ -140,14 +138,11 @@ function DraftInput({
const theme = useTheme();
const isTablet = useIsTablet();
const {inputRef, focusInput: focus} = useKeyboardAnimationContext();
const handleLayout = useCallback((e: LayoutChangeEvent) => {
updatePostInputTop(e.nativeEvent.layout.height);
}, []);
const inputRef = useRef<PasteInputRef>();
const focus = useCallback(() => {
inputRef.current?.focus();
}, []);
}, [updatePostInputTop]);
// Render
const postInputTestID = `${testID}.post.input`;

View file

@ -2,13 +2,10 @@
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {Platform} from 'react-native';
import Autocomplete from '@components/autocomplete';
import {ExtraKeyboard} from '@context/extra_keyboard';
import {useServerUrl} from '@context/server';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useKeyboardHeight} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import Archived from './archived';
@ -55,8 +52,6 @@ function PostDraft({
const [cursorPosition, setCursorPosition] = useState(message.length);
const [postInputTop, setPostInputTop] = useState(0);
const [isFocused, setIsFocused] = useState(false);
const keyboardHeight = useKeyboardHeight();
const kbHeight = Platform.OS === 'ios' ? keyboardHeight : 0; // useKeyboardHeight is already deducting the keyboard height on Android
const headerHeight = useDefaultHeaderHeight();
const serverUrl = useServerUrl();
@ -64,9 +59,9 @@ function PostDraft({
useEffect(() => {
setValue(message);
setCursorPosition(message.length);
}, [channelId, rootId]);
}, [channelId, message, rootId]);
const autocompletePosition = AUTOCOMPLETE_ADJUST + kbHeight + postInputTop;
const autocompletePosition = postInputTop + AUTOCOMPLETE_ADJUST;
const autocompleteAvailableSpace = containerHeight - autocompletePosition - (isChannelScreen ? headerHeight : 0);
const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace);
@ -120,6 +115,7 @@ function PostDraft({
shouldDirectlyReact={!Boolean(files?.length)}
availableSpace={animatedAutocompleteAvailableSpace}
serverUrl={serverUrl}
usePortal={true}
/>
) : null;
@ -127,7 +123,6 @@ function PostDraft({
<>
{draftHandler}
{autoComplete}
{Platform.OS !== 'android' && <ExtraKeyboard/>}
</>
);
}

View file

@ -10,15 +10,18 @@ import {
Alert, AppState, type AppStateStatus, DeviceEventEmitter, type EmitterSubscription, Keyboard,
type NativeSyntheticEvent, Platform, type TextInputSelectionChangeEventData,
} from 'react-native';
import {runOnUI} from 'react-native-reanimated';
import {updateDraftMessage} from '@actions/local/draft';
import {userTyping} from '@actions/websocket/users';
import {Events, Screens} from '@constants';
import {useExtraKeyboardContext} from '@context/extra_keyboard';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useInputPropagation} from '@hooks/input';
import {useFocusAfterEmojiDismiss} from '@hooks/useFocusAfterEmojiDismiss';
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
import NavigationStore from '@store/navigation_store';
import {handleDraftUpdate} from '@utils/draft';
import {extractFileInfo} from '@utils/file';
@ -122,7 +125,41 @@ export default function PostInput({
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const managedConfig = useManagedConfig<ManagedConfig>();
const keyboardContext = useExtraKeyboardContext();
const {
setShowInputAccessoryView,
showInputAccessoryView,
isInputAccessoryViewMode,
inputAccessoryViewAnimatedHeight,
keyboardTranslateY,
isTransitioningFromCustomView,
setIsEmojiSearchFocused,
isEmojiSearchFocused,
keyboardHeight,
lastKeyboardHeight,
bottomInset,
scrollOffset,
registerCursorPosition,
registerPostInputCallbacks,
} = useKeyboardAnimationContext();
// Register cursor position updates with context
useEffect(() => {
if (showInputAccessoryView) {
return;
}
if (registerCursorPosition) {
registerCursorPosition(cursorPosition);
}
}, [registerCursorPosition, cursorPosition, showInputAccessoryView]);
// Register updateValue and updateCursorPosition with context
useEffect(() => {
if (registerPostInputCallbacks) {
registerPostInputCallbacks(updateValue, updateCursorPosition);
}
}, [registerPostInputCallbacks, updateValue, updateCursorPosition]);
const [propagateValue, shouldProcessEvent] = useInputPropagation();
const lastTypingEventSent = useRef(0);
@ -132,22 +169,25 @@ export default function PostInput({
const [longMessageAlertShown, setLongMessageAlertShown] = useState(false);
// Handle focus after emoji picker dismissal
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, [inputRef]);
const {
focus: focusWithEmojiDismiss,
isDismissingEmojiPicker,
focusTimeoutRef,
isManuallyFocusingAfterEmojiDismiss,
} = useFocusAfterEmojiDismiss(inputRef, focusInput);
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
const maxHeight = isTablet ? 150 : 88;
const pasteInputStyle = useMemo(() => {
return {...style.input, maxHeight};
}, [maxHeight, style.input]);
const handleAndroidKeyboardHide = () => {
onBlur();
};
const handleAndroidKeyboardShow = () => {
onFocus();
};
const onBlur = useCallback(() => {
keyboardContext?.registerTextInputBlur();
handleDraftUpdate({
serverUrl,
channelId,
@ -155,12 +195,111 @@ export default function PostInput({
value,
});
setIsFocused(false);
}, [keyboardContext, serverUrl, channelId, rootId, value, setIsFocused]);
}, [serverUrl, channelId, rootId, value, setIsFocused]);
// Handle press event (fires BEFORE onFocus) - dismiss emoji picker before keyboard opens
const handlePress = useCallback(() => {
focusWithEmojiDismiss();
}, [focusWithEmojiDismiss]);
const onFocus = useCallback(() => {
keyboardContext?.registerTextInputFocus();
// Ignore focus events during emoji picker dismissal - handled manually
if (Platform.OS === 'android' && (isDismissingEmojiPicker.current || focusTimeoutRef.current || isManuallyFocusingAfterEmojiDismiss)) {
return;
}
// On Android, ignore focus events when emoji search is focused
// This prevents the emoji picker from closing when the search bar gets focus
if (Platform.OS === 'android' && isEmojiSearchFocused) {
return;
}
setIsFocused(true);
}, [setIsFocused, keyboardContext]);
// Reset emoji search focus immediately to prevent jumping
// This must happen before closing the emoji picker
setIsEmojiSearchFocused(false);
// Close emoji picker immediately
setShowInputAccessoryView(false);
if (Platform.OS === 'android') {
keyboardTranslateY.value = inputAccessoryViewAnimatedHeight.value;
inputAccessoryViewAnimatedHeight.value = 0;
isInputAccessoryViewMode.value = false;
// IMPORTANT: Reset isTransitioningFromCustomView when keyboard opens
// This ensures emoji picker can be opened again after keyboard appears
isTransitioningFromCustomView.value = false;
// Reset bottomInset and scrollOffset so the scroll restoration can trigger when emoji picker closes
bottomInset.value = 0;
scrollOffset.value = 0;
return;
}
// Transition from emoji picker to keyboard
if (showInputAccessoryView) {
// Use actual keyboard height instead of emoji picker height to ensure consistency
// This prevents height accumulation when transitioning multiple times
// Use default keyboard height if no keyboard height has been recorded yet
// This prevents input container from going to bottom when keyboard hasn't been opened
const targetKeyboardHeight = keyboardHeight.value || lastKeyboardHeight || DEFAULT_INPUT_ACCESSORY_HEIGHT;
// Set transition flag FIRST synchronously to prevent keyboard handlers from interfering
// This must be set before disabling input accessory view mode to avoid race conditions
isTransitioningFromCustomView.value = true;
// Collapse emoji picker instantly
inputAccessoryViewAnimatedHeight.value = 0;
// Set input container height to keyboard height to ensure correct final position
// This ensures the height always matches the keyboard, preventing accumulation
keyboardTranslateY.value = targetKeyboardHeight;
// Use runOnUI to disable input accessory view mode atomically
// This ensures the transition flag is visible when keyboard handlers start processing
runOnUI(() => {
'worklet';
// Disable custom view mode to allow keyboard handlers to work
// This is done AFTER setting transition flag to prevent race conditions
isInputAccessoryViewMode.value = false;
})();
// Safety net: In rare cases (app backgrounding, system interruptions, rapid toggling),
// the keyboard onEnd event might not fire, leaving us stuck in transition state.
// This timeout ensures we recover after 1 second if that happens.
setTimeout(() => {
if (isTransitioningFromCustomView.value) {
isTransitioningFromCustomView.value = false;
}
}, 1000);
}
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isDismissingEmojiPicker,
focusTimeoutRef,
isManuallyFocusingAfterEmojiDismiss,
isEmojiSearchFocused,
setIsFocused,
setIsEmojiSearchFocused,
setShowInputAccessoryView,
showInputAccessoryView,
lastKeyboardHeight,
]);
const handleAndroidKeyboardHide = useCallback(() => {
onBlur();
}, [onBlur]);
const handleAndroidKeyboardShow = useCallback(() => {
onFocus();
}, [onFocus]);
const checkMessageLength = useCallback((newValue: string) => {
const valueLength = newValue.trim().length;
@ -189,10 +328,13 @@ export default function PostInput({
}, [intl, longMessageAlertShown, maxMessageLength]);
const handlePostDraftSelectionChanged = useCallback((event: NativeSyntheticEvent<TextInputSelectionChangeEventData> | null, fromHandleTextChange = false) => {
if (showInputAccessoryView && !fromHandleTextChange) {
return;
}
const cp = fromHandleTextChange ? cursorPosition : event!.nativeEvent.selection.end;
updateCursorPosition(cp);
}, [updateCursorPosition, cursorPosition]);
}, [showInputAccessoryView, cursorPosition, updateCursorPosition]);
const handleTextChange = useCallback((newValue: string) => {
if (!shouldProcessEvent(newValue)) {
@ -286,7 +428,7 @@ export default function PostInput({
keyboardShowListener?.remove();
keyboardHideListener?.remove();
});
}, []);
}, [handleAndroidKeyboardHide, handleAndroidKeyboardShow]);
useEffect(() => {
const listener = AppState.addEventListener('change', onAppStateChange);
@ -311,6 +453,12 @@ export default function PostInput({
listener.remove();
updateDraftMessage(serverUrl, channelId, rootId, lastNativeValue.current); // safe draft on unmount
};
// - updateValue, updateCursorPosition, propagateValue are stable setState/hook functions
// - inputRef is a ref (stable reference, doesn't need to be in deps)
// - serverUrl, value, lastNativeValue are either stable or we want their latest values when event fires
// - We need to recreate the listener when channelId/rootId changes to check the correct source screen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateValue, channelId, rootId]);
useEffect(() => {
@ -318,6 +466,10 @@ export default function PostInput({
propagateValue(value);
lastNativeValue.current = value;
}
// - propagateValue is from useInputPropagation hook (stable reference, doesn't need to be in deps)
// - lastNativeValue is a ref (stable reference, doesn't need to be in deps)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const events = useMemo(() => ({
@ -336,11 +488,13 @@ export default function PostInput({
onBlur={onBlur}
onChangeText={handleTextChange}
onFocus={onFocus}
onPress={Platform.OS === 'android' ? handlePress : undefined}
onPaste={onPaste}
onSelectionChange={handlePostDraftSelectionChanged}
placeholder={intl.formatMessage(getPlaceHolder(rootId), {channelDisplayName})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
ref={inputRef}
showSoftInputOnFocus={Platform.OS === 'android' ? (!showInputAccessoryView || isManuallyFocusingAfterEmojiDismiss) : true}
smartPunctuation='disable'
submitBehavior='newline'
style={pasteInputStyle}
@ -349,6 +503,7 @@ export default function PostInput({
textContentType='none'
value={value}
autoCapitalize='sentences'
nativeID={testID}
/>
);
}

View file

@ -0,0 +1,306 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, waitFor} from '@testing-library/react-native';
import {KeyboardController} from 'react-native-keyboard-controller';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {openAttachmentOptions} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import AttachmentQuickAction from '.';
jest.mock('react-native-keyboard-controller', () => ({
KeyboardController: {
dismiss: jest.fn(() => Promise.resolve()),
},
}));
jest.mock('@context/keyboard_animation', () => ({
useKeyboardAnimationContext: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
openAttachmentOptions: jest.fn(),
}));
describe('AttachmentQuickAction', () => {
const mockCloseInputAccessoryView = jest.fn();
const mockKeyboardControllerDismiss = jest.mocked(KeyboardController.dismiss);
const mockOpenAttachmentOptions = jest.mocked(openAttachmentOptions);
const mockUseKeyboardAnimationContext = jest.mocked(useKeyboardAnimationContext);
const baseProps = {
disabled: false,
fileCount: 0,
onUploadFiles: jest.fn(),
maxFilesReached: false,
maxFileCount: 10,
testID: 'test-attachment',
};
beforeEach(() => {
jest.clearAllMocks();
mockUseKeyboardAnimationContext.mockReturnValue({
closeInputAccessoryView: mockCloseInputAccessoryView,
} as unknown as ReturnType<typeof useKeyboardAnimationContext>);
});
describe('user interactions', () => {
it('should call onUploadFiles when button is pressed', async () => {
const onUploadFiles = jest.fn();
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
onUploadFiles={onUploadFiles}
/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockCloseInputAccessoryView).toHaveBeenCalledTimes(1);
expect(mockKeyboardControllerDismiss).toHaveBeenCalledTimes(1);
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
});
});
it('should not call onUploadFiles when button is disabled', async () => {
const onUploadFiles = jest.fn();
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
disabled={true}
onUploadFiles={onUploadFiles}
/>,
);
const button = getByTestId('test-attachment.disabled');
fireEvent.press(button);
// Should not trigger any actions when disabled
expect(mockCloseInputAccessoryView).not.toHaveBeenCalled();
expect(mockKeyboardControllerDismiss).not.toHaveBeenCalled();
expect(mockOpenAttachmentOptions).not.toHaveBeenCalled();
});
it('should close input accessory view before opening bottom sheet', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...baseProps}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
// closeInputAccessoryView should be called before openAttachmentOptions
expect(mockCloseInputAccessoryView).toHaveBeenCalledTimes(1);
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
});
});
it('should dismiss keyboard before opening bottom sheet', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...baseProps}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockKeyboardControllerDismiss).toHaveBeenCalledTimes(1);
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
});
});
});
describe('bottom sheet opening', () => {
it('should call openAttachmentOptions with intl, theme, and props', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...baseProps}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledTimes(1);
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
onUploadFiles: baseProps.onUploadFiles,
maxFilesReached: false,
canUploadFiles: true,
testID: 'test-attachment',
fileCount: 0,
maxFileCount: 10,
}),
);
});
});
it('should pass correct props to openAttachmentOptions', async () => {
const onUploadFiles = jest.fn();
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
onUploadFiles={onUploadFiles}
fileCount={5}
maxFilesReached={false}
maxFileCount={10}
disabled={false}
testID='custom-test-id'
/>,
);
const button = getByTestId('custom-test-id');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
onUploadFiles,
fileCount: 5,
maxFilesReached: false,
canUploadFiles: true,
testID: 'custom-test-id',
maxFileCount: 10,
}),
);
});
});
it('should pass canUploadFiles as false when disabled', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
disabled={true}
/>,
);
const button = getByTestId('test-attachment.disabled');
fireEvent.press(button);
// When disabled, openAttachmentOptions should not be called
expect(mockOpenAttachmentOptions).not.toHaveBeenCalled();
});
it('should pass intl and theme to openAttachmentOptions', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...baseProps}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.objectContaining({
formatMessage: expect.any(Function),
}), // intl
expect.objectContaining({
centerChannelColor: expect.anything(),
}), // theme
expect.any(Object), // props
);
});
});
});
describe('edge cases', () => {
it('should handle fileCount prop correctly', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
fileCount={3}
/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
fileCount: 3,
}),
);
});
});
it('should handle maxFilesReached prop correctly', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
maxFilesReached={true}
/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
maxFilesReached: true,
}),
);
});
});
it('should handle maxFileCount prop correctly', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction
{...baseProps}
maxFileCount={20}
/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
maxFileCount: 20,
}),
);
});
});
it('should handle default fileCount when not provided', async () => {
const propsWithoutFileCount = {
...baseProps,
};
delete (propsWithoutFileCount as {fileCount?: number}).fileCount;
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentQuickAction {...propsWithoutFileCount}/>,
);
const button = getByTestId('test-attachment');
fireEvent.press(button);
await waitFor(() => {
expect(mockOpenAttachmentOptions).toHaveBeenCalledWith(
expect.any(Object), // intl
expect.any(Object), // theme
expect.objectContaining({
fileCount: 0,
}),
);
});
});
});
});

View file

@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {openAttachmentOptions} from '@screens/navigation';
import {dismissKeyboard} from '@utils/keyboard';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
iconContainer: {
display: 'flex',
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
borderRadius: 20,
alignItems: 'center',
alignSelf: 'center',
justifyContent: 'center',
width: 40,
height: 40,
marginRight: 8,
},
icon: {
alignSelf: 'center',
},
};
});
export default function AttachmentQuickAction({
disabled,
fileCount = 0,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const {closeInputAccessoryView} = useKeyboardAnimationContext();
const style = getStyleSheet(theme);
const iconColor = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
const openFileAttachmentOptions = useCallback(async () => {
closeInputAccessoryView();
await dismissKeyboard();
openAttachmentOptions(intl, theme, {
onUploadFiles,
maxFilesReached,
canUploadFiles: !disabled,
testID,
fileCount,
maxFileCount,
});
}, [closeInputAccessoryView, intl, theme, onUploadFiles, maxFilesReached, disabled, testID, fileCount, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={openFileAttachmentOptions}
style={style.iconContainer}
type={'opacity'}
>
<CompassIcon
name='plus'
size={ICON_SIZE}
color={iconColor}
style={style.icon}
/>
</TouchableWithFeedback>
);
}

View file

@ -1,84 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import SlideUpPanelItem from '@components/slide_up_panel_item';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {dismissBottomSheet} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {CameraOptions} from 'react-native-image-picker';
type Props = {
onPress: (options: CameraOptions) => void;
}
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
title: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
marginBottom: 8,
},
}));
const CameraType = ({onPress}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
const style = getStyle(theme);
const intl = useIntl();
const onPhoto = async () => {
const options: CameraOptions = {
quality: 0.8,
mediaType: 'photo',
saveToPhotos: true,
};
await dismissBottomSheet();
onPress(options);
};
const onVideo = async () => {
const options: CameraOptions = {
videoQuality: 'high',
mediaType: 'video',
saveToPhotos: true,
};
await dismissBottomSheet();
onPress(options);
};
return (
<View>
{!isTablet &&
<FormattedText
id='mobile.camera_type.title'
defaultMessage='Camera options'
style={style.title}
/>
}
<SlideUpPanelItem
leftIcon='camera-outline'
onPress={onPhoto}
testID='camera_type.photo'
text={intl.formatMessage({id: 'camera_type.photo.option', defaultMessage: 'Capture Photo'})}
/>
<SlideUpPanelItem
leftIcon='video-outline'
onPress={onVideo}
testID='camera_type.video'
text={intl.formatMessage({id: 'camera_type.video.option', defaultMessage: 'Record Video'})}
/>
</View>
);
};
export default CameraType;

View file

@ -1,97 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import {bottomSheet} from '@screens/navigation';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {changeOpacity} from '@utils/theme';
import CameraType from './camera_type';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
import type {CameraOptions} from 'react-native-image-picker';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function CameraQuickAction({
disabled,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID,
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback((options: CameraOptions) => {
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromCamera(options);
}, [intl, onUploadFiles]);
const renderContent = useCallback(() => {
return (
<CameraType
onPress={handleButtonPress}
/>
);
}, [handleButtonPress]);
const openSelectorModal = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
bottomSheet({
title: intl.formatMessage({id: 'mobile.camera_type.title', defaultMessage: 'Camera options'}),
renderContent,
snapPoints: [1, bottomSheetSnapPoint(2, ITEM_HEIGHT) + TITLE_HEIGHT],
theme,
closeButtonId: 'camera-close-id',
});
}, [intl, theme, renderContent, maxFilesReached, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={openSelectorModal}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='camera-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import {Platform, StyleSheet} from 'react-native';
import {runOnJS, runOnUI} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
import {usePreventDoubleTap} from '@hooks/utils';
import {dismissKeyboard, isKeyboardVisible} from '@utils/keyboard';
import {changeOpacity} from '@utils/theme';
type Props = {
testID?: string;
disabled?: boolean;
};
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function EmojiQuickAction({
testID,
disabled,
}: Props) {
const theme = useTheme();
const {
isInputAccessoryViewMode,
isTransitioningFromCustomView,
keyboardHeight,
lastKeyboardHeight,
inputAccessoryViewAnimatedHeight,
showInputAccessoryView,
setShowInputAccessoryView,
isKeyboardFullyClosed,
} = useKeyboardAnimationContext();
const showEmojiPicker = useCallback(() => {
setShowInputAccessoryView(true);
}, [setShowInputAccessoryView]);
const checkCallbackRef = useRef<(() => void) | null>(null);
const scheduleKeyboardCheck = useCallback(() => {
const checkKeyboard = () => {
runOnUI(() => {
'worklet';
const currentKeyboardHeight = keyboardHeight.value;
const targetHeight = currentKeyboardHeight || lastKeyboardHeight || DEFAULT_INPUT_ACCESSORY_HEIGHT;
if (isKeyboardFullyClosed.value || keyboardHeight.value === 0) {
// Match iOS order: Set SharedValues first, then trigger React render
// This ensures values are set before emoji picker component mounts
isInputAccessoryViewMode.value = true;
inputAccessoryViewAnimatedHeight.value = targetHeight;
// Trigger React render to mount emoji picker component
runOnJS(showEmojiPicker)();
} else if (checkCallbackRef.current) {
runOnJS(checkCallbackRef.current)();
}
})();
};
checkCallbackRef.current = () => {
requestAnimationFrame(checkKeyboard);
};
checkKeyboard();
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastKeyboardHeight, showEmojiPicker]);
const handleButtonPress = usePreventDoubleTap(useCallback(() => {
// Prevent opening if already showing or transitioning
if (disabled || showInputAccessoryView || isTransitioningFromCustomView.value) {
return;
}
if (Platform.OS === 'android' && isKeyboardVisible()) {
dismissKeyboard();
// Wait for keyboard to be fully dismissed before showing emoji picker
// This prevents the emoji picker from appearing above the keyboard
// Start checking after a small delay to give keyboard dismissal time to start
setTimeout(scheduleKeyboardCheck, 50);
return;
}
// CRITICAL: Execute all shared value updates atomically on UI thread.
// Why? When KeyboardController.dismiss() is called, it immediately fires keyboard events
// (onStart, onMove, onEnd) on the UI thread. If we set isInputAccessoryViewMode on the
// JS thread, there's a race condition - the keyboard handlers might fire BEFORE the flag
// propagates to the UI thread, causing them to process the dismiss event incorrectly.
// By running everything on UI thread, we guarantee isInputAccessoryViewMode is set
// BEFORE keyboard events fire, ensuring they are properly ignored.
runOnUI(() => {
'worklet';
// Determine target height for emoji picker
const currentKeyboardHeight = keyboardHeight.value;
const targetHeight = currentKeyboardHeight || lastKeyboardHeight || DEFAULT_INPUT_ACCESSORY_HEIGHT;
// Enable custom view mode before keyboard dismissal to prevent keyboard handlers from interfering
isInputAccessoryViewMode.value = true;
// Set emoji picker height to target
// Note: Don't modify `height` yet - it will be set to 0 after emoji picker mounts
// This keeps the input container at the keyboard position while emoji picker renders
inputAccessoryViewAnimatedHeight.value = targetHeight;
// Trigger React render to mount emoji picker component
runOnJS(setShowInputAccessoryView)(true);
// Dismiss keyboard
runOnJS(dismissKeyboard)();
})();
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled, showInputAccessoryView, lastKeyboardHeight, setShowInputAccessoryView, scheduleKeyboardCheck]));
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='emoticon-happy-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -1,72 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function FileQuickAction({
disabled,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromFiles(undefined, true);
}, [intl, maxFileCount, maxFilesReached, onUploadFiles]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='paperclip'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -1,74 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {changeOpacity} from '@utils/theme';
import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action';
const style = StyleSheet.create({
icon: {
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
});
export default function ImageQuickAction({
disabled,
fileCount = 0,
onUploadFiles,
maxFilesReached,
maxFileCount,
testID = '',
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const handleButtonPress = useCallback(() => {
if (maxFilesReached) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return;
}
const picker = new PickerUtil(intl,
onUploadFiles);
picker.attachFileFromPhotoGallery(maxFileCount - fileCount);
}, [onUploadFiles, fileCount, maxFileCount]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={disabled}
onPress={handleButtonPress}
style={style.icon}
type={'opacity'}
>
<CompassIcon
color={color}
name='image-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -1,55 +1,337 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper';
import InputQuickAction from '.';
jest.mock('@context/keyboard_animation', () => ({
useKeyboardAnimationContext: jest.fn(),
}));
jest.mock('@hooks/useFocusAfterEmojiDismiss', () => ({
useFocusAfterEmojiDismiss: jest.fn((inputRef, focusInput) => ({
focus: focusInput,
isDismissingEmojiPicker: {current: false},
focusTimeoutRef: {current: null},
isManuallyFocusingAfterEmojiDismiss: false,
})),
}));
describe('InputQuickAction', () => {
it('should add If theres existing text and it doesnt end with a space, add a space before @', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
const mockUseKeyboardAnimationContext = jest.mocked(useKeyboardAnimationContext);
const mockUpdateCursorPosition = jest.fn();
const mockInputRef = {current: undefined};
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('')).toBe('@');
expect(focus).toHaveBeenCalled();
beforeEach(() => {
jest.clearAllMocks();
});
it('should add space before @ if there is existing text and it doesnt end with a space', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
describe('fallback behavior (no cursor position context)', () => {
beforeEach(() => {
mockUseKeyboardAnimationContext.mockReturnValue({
inputRef: mockInputRef,
cursorPositionRef: undefined,
updateCursorPosition: undefined,
} as unknown as ReturnType<typeof useKeyboardAnimationContext>);
});
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
it('should add @ to empty string', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
const icon = getByTestId('test-id');
fireEvent.press(icon);
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('Hello @');
expect(focus).toHaveBeenCalled();
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('')).toBe('@');
expect(focus).toHaveBeenCalled();
});
it('should add space before @ if there is existing text and it doesnt end with a space', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('Hello @');
expect(focus).toHaveBeenCalled();
});
});
describe('cursor position insertion', () => {
let cursorPositionRef: {current: number};
beforeEach(() => {
cursorPositionRef = {current: 0};
mockUseKeyboardAnimationContext.mockReturnValue({
inputRef: mockInputRef,
cursorPositionRef,
updateCursorPosition: mockUpdateCursorPosition,
} as unknown as ReturnType<typeof useKeyboardAnimationContext>);
});
describe('@ input type', () => {
it('should insert @ at cursor position at the beginning', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
cursorPositionRef.current = 0;
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('@Hello');
expect(cursorPositionRef.current).toBe(1);
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1);
expect(focus).toHaveBeenCalled();
});
it('should insert @ at cursor position in the middle', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
cursorPositionRef.current = 3;
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('Hel@lo');
expect(cursorPositionRef.current).toBe(4);
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(4);
expect(focus).toHaveBeenCalled();
});
it('should insert @ at cursor position at the end', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
cursorPositionRef.current = 5;
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('Hello @');
expect(cursorPositionRef.current).toBe(7);
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7);
expect(focus).toHaveBeenCalled();
});
it('should add space before @ when cursor is at the end and previous char is not space', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
cursorPositionRef.current = 5;
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('Hello @');
expect(cursorPositionRef.current).toBe(7);
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7);
expect(focus).toHaveBeenCalled();
});
it('should not add space before @ when cursor is at the end and previous char is space', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
cursorPositionRef.current = 6;
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello ')).toBe('Hello @');
expect(cursorPositionRef.current).toBe(7);
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7);
expect(focus).toHaveBeenCalled();
});
it('should not add space before @ when cursor is at the beginning', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'at';
const focus = jest.fn();
cursorPositionRef.current = 0;
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('@Hello');
expect(cursorPositionRef.current).toBe(1);
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1);
expect(focus).toHaveBeenCalled();
});
});
describe('slash input type', () => {
it('should insert / at cursor position at the beginning', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'slash';
const focus = jest.fn();
cursorPositionRef.current = 0;
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('/Hello');
expect(cursorPositionRef.current).toBe(1);
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1);
expect(focus).toHaveBeenCalled();
});
it('should insert / at cursor position in the middle', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'slash';
const focus = jest.fn();
cursorPositionRef.current = 3;
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('Hel/lo');
expect(cursorPositionRef.current).toBe(4);
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(4);
expect(focus).toHaveBeenCalled();
});
it('should insert / at cursor position at the end', () => {
const updateValue = jest.fn();
const testID = 'test-id';
const inputType = 'slash';
const focus = jest.fn();
cursorPositionRef.current = 5;
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
const icon = getByTestId('test-id');
fireEvent.press(icon);
expect(updateValue).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = updateValue.mock.calls[0][0];
expect(updateFunction('Hello')).toBe('Hello/');
expect(cursorPositionRef.current).toBe(6);
expect(mockUpdateCursorPosition).toHaveBeenCalledWith(6);
expect(focus).toHaveBeenCalled();
});
});
});
});

View file

@ -6,7 +6,9 @@ import React, {useCallback} from 'react';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {useFocusAfterEmojiDismiss} from '@hooks/useFocusAfterEmojiDismiss';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
@ -38,19 +40,54 @@ export default function InputQuickAction({
focus,
}: Props) {
const theme = useTheme();
const {inputRef, cursorPositionRef, updateCursorPosition} = useKeyboardAnimationContext();
// Use hook to handle focus after emoji picker dismissal
const {focus: focusWithEmojiDismiss} = useFocusAfterEmojiDismiss(inputRef, focus);
const onPress = useCallback(() => {
updateValue((v) => {
if (inputType === 'at') {
// If there's existing text and it doesn't end with a space, add a space before @
if (v.length > 0 && !v.endsWith(' ')) {
return `${v} @`;
if (cursorPositionRef && updateCursorPosition) {
const currentCursorPosition = cursorPositionRef.current;
updateValue((v) => {
if (inputType === 'at') {
let insertedText = '@';
const charBeforeCursor = currentCursorPosition > 0 ? v[currentCursorPosition - 1] : '';
if (currentCursorPosition === v.length && currentCursorPosition > 0 && charBeforeCursor !== ' ') {
insertedText = ' @';
}
const newValue = v.slice(0, currentCursorPosition) + insertedText + v.slice(currentCursorPosition);
const newCursorPosition = currentCursorPosition + insertedText.length;
cursorPositionRef.current = newCursorPosition;
updateCursorPosition(newCursorPosition);
return newValue;
}
return `${v}@`;
}
return '/';
});
focus();
}, [inputType, updateValue, focus]);
const newValue = v.slice(0, currentCursorPosition) + '/' + v.slice(currentCursorPosition);
const newCursorPosition = currentCursorPosition + 1;
cursorPositionRef.current = newCursorPosition;
updateCursorPosition(newCursorPosition);
return newValue;
});
} else {
updateValue((v) => {
if (inputType === 'at') {
if (v.length > 0 && !v.endsWith(' ')) {
return `${v} @`;
}
return `${v}@`;
}
return '/';
});
}
focusWithEmojiDismiss();
}, [inputType, updateValue, focusWithEmojiDismiss, cursorPositionRef, updateCursorPosition]);
const actionTestID = disabled ?
`${testID}.disabled` :

View file

@ -3,15 +3,17 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet} from 'react-native';
import {StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens} from '@constants';
import {ICON_SIZE} from '@constants/post_draft';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {openAsBottomSheet} from '@screens/navigation';
import {dismissKeyboard} from '@utils/keyboard';
import {changeOpacity} from '@utils/theme';
type Props = {
@ -38,9 +40,11 @@ export default function PostPriorityAction({
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
const {closeInputAccessoryView} = useKeyboardAnimationContext();
const onPress = useCallback(() => {
Keyboard.dismiss();
const onPress = useCallback(async () => {
closeInputAccessoryView();
await dismissKeyboard();
const title = isTablet ? intl.formatMessage({id: 'post_priority.picker.title', defaultMessage: 'Message priority'}) : '';
@ -55,7 +59,7 @@ export default function PostPriorityAction({
closeButtonId: POST_PRIORITY_PICKER_BUTTON,
},
});
}, [isTablet, intl, theme, postPriority, updatePostPriority]);
}, [closeInputAccessoryView, isTablet, intl, theme, postPriority, updatePostPriority]);
const iconName = 'alert-circle-outline';
const iconColor = changeOpacity(theme.centerChannelColor, 0.64);

View file

@ -8,6 +8,7 @@ import QuickActions from './quick_actions';
describe('Quick Actions', () => {
const baseProps: Parameters<typeof QuickActions>[0] = {
testID: 'test-quick-actions',
canUploadFiles: true,
fileCount: 0,
isPostPriorityEnabled: true,
@ -24,21 +25,149 @@ describe('Quick Actions', () => {
focus: jest.fn(),
};
it('Should render slash command if canShowSlashCommands is true', () => {
const props = {
...baseProps,
canShowSlashCommands: true,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('slash-input-action')).toBeDefined();
describe('slash commands', () => {
it('should render slash command if canShowSlashCommands is true', () => {
const props = {
...baseProps,
canShowSlashCommands: true,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.slash_input_action')).toBeDefined();
});
it('should not render slash command if canShowSlashCommands is false', () => {
const props = {
...baseProps,
canShowSlashCommands: false,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.slash_input_action')).toBeNull();
});
it('should render slash command by default when canShowSlashCommands is not provided', () => {
const props = {
...baseProps,
};
delete (props as {canShowSlashCommands?: boolean}).canShowSlashCommands;
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.slash_input_action')).toBeDefined();
});
});
it('Should not render slash command if canShowSlashCommands is false', () => {
const props = {
...baseProps,
canShowSlashCommands: false,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('slash-input-action')).toBeNull();
describe('post priority', () => {
it('should render post priority action when both isPostPriorityEnabled and canShowPostPriority are true', () => {
const props = {
...baseProps,
isPostPriorityEnabled: true,
canShowPostPriority: true,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeDefined();
});
it('should not render post priority action when isPostPriorityEnabled is false', () => {
const props = {
...baseProps,
isPostPriorityEnabled: false,
canShowPostPriority: true,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeNull();
});
it('should not render post priority action when canShowPostPriority is false', () => {
const props = {
...baseProps,
isPostPriorityEnabled: true,
canShowPostPriority: false,
};
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeNull();
});
it('should not render post priority action when canShowPostPriority is undefined', () => {
const props = {
...baseProps,
isPostPriorityEnabled: true,
};
delete (props as {canShowPostPriority?: boolean}).canShowPostPriority;
const {queryByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
expect(queryByTestId('test-quick-actions.post_priority_action')).toBeNull();
});
});
describe('input action disabled states', () => {
it('should disable at input action when value ends with @', () => {
const props = {
...baseProps,
value: 'test @',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const atAction = getByTestId('test-quick-actions.at_input_action.disabled');
expect(atAction).toBeDisabled();
});
it('should enable at input action when value does not end with @', () => {
const props = {
...baseProps,
value: 'test message',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const atAction = getByTestId('test-quick-actions.at_input_action');
expect(atAction).not.toBeDisabled();
});
it('should enable at input action when value is empty', () => {
const props = {
...baseProps,
value: '',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const atAction = getByTestId('test-quick-actions.at_input_action');
expect(atAction).not.toBeDisabled();
});
it('should disable slash input action when value is not empty', () => {
const props = {
...baseProps,
value: 'test message',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const slashAction = getByTestId('test-quick-actions.slash_input_action.disabled');
expect(slashAction).toBeDisabled();
});
it('should enable slash input action when value is empty', () => {
const props = {
...baseProps,
value: '',
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const slashAction = getByTestId('test-quick-actions.slash_input_action');
expect(slashAction).not.toBeDisabled();
});
});
describe('attachment action props', () => {
it('should pass disabled=true when canUploadFiles is false', () => {
const props = {
...baseProps,
canUploadFiles: false,
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const attachmentAction = getByTestId('test-quick-actions.attachment_action.disabled');
expect(attachmentAction).toBeDisabled();
});
it('should pass disabled=false when canUploadFiles is true', () => {
const props = {
...baseProps,
canUploadFiles: true,
};
const {getByTestId} = renderWithIntlAndTheme(<QuickActions {...props}/>);
const attachmentAction = getByTestId('test-quick-actions.attachment_action');
expect(attachmentAction).not.toBeDisabled();
});
});
});

View file

@ -4,9 +4,8 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
import CameraAction from './camera_quick_action';
import FileAction from './file_quick_action';
import ImageAction from './image_quick_action';
import AttachmentAction from './attachment_quick_action';
import EmojiAction from './emoji_quick_action';
import InputAction from './input_quick_action';
import PostPriorityAction from './post_priority_action';
@ -17,6 +16,7 @@ type Props = {
isPostPriorityEnabled: boolean;
canShowPostPriority?: boolean;
canShowSlashCommands?: boolean;
canShowEmojiPicker?: boolean;
maxFileCount: number;
// Draft Handler
@ -35,6 +35,7 @@ const style = StyleSheet.create({
display: 'flex',
flexDirection: 'row',
height: QUICK_ACTIONS_HEIGHT,
marginLeft: 8,
},
});
@ -46,6 +47,7 @@ export default function QuickActions({
isPostPriorityEnabled,
canShowSlashCommands = true,
canShowPostPriority,
canShowEmojiPicker = true,
maxFileCount,
updateValue,
addFiles,
@ -58,9 +60,8 @@ export default function QuickActions({
const atInputActionTestID = `${testID}.at_input_action`;
const slashInputActionTestID = `${testID}.slash_input_action`;
const fileActionTestID = `${testID}.file_action`;
const imageActionTestID = `${testID}.image_action`;
const cameraActionTestID = `${testID}.camera_action`;
const emojiActionTestID = `${testID}.emoji_action`;
const attachmentActionTestID = `${testID}.attachment_action`;
const postPriorityActionTestID = `${testID}.post_priority_action`;
const uploadProps = {
@ -76,6 +77,10 @@ export default function QuickActions({
testID={testID}
style={style.quickActionsContainer}
>
<AttachmentAction
testID={attachmentActionTestID}
{...uploadProps}
/>
<InputAction
testID={atInputActionTestID}
disabled={atDisabled}
@ -92,18 +97,11 @@ export default function QuickActions({
focus={focus}
/>
)}
<FileAction
testID={fileActionTestID}
{...uploadProps}
/>
<ImageAction
testID={imageActionTestID}
{...uploadProps}
/>
<CameraAction
testID={cameraActionTestID}
{...uploadProps}
/>
{canShowEmojiPicker && (
<EmojiAction
testID={emojiActionTestID}
/>
)}
{isPostPriorityEnabled && canShowPostPriority && (
<PostPriorityAction
testID={postPriorityActionTestID}

View file

@ -7,6 +7,7 @@ import {Text, TouchableOpacity, View} from 'react-native';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import CompassIcon from '@components/compass_icon';
import {SEARCH} from '@constants/screens';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {usePreventDoubleTap} from '@hooks/utils';
import {makeStyleSheetFromTheme} from '@utils/theme';
@ -44,11 +45,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) => {
const style = getStyleSheet(theme);
const serverUrl = useServerUrl();
const {blurAndDismissKeyboard} = useKeyboardAnimationContext();
const onPress = usePreventDoubleTap(useCallback(async () => {
await blurAndDismissKeyboard();
const onPress = usePreventDoubleTap(useCallback(() => {
const rootId = post.rootId || post.id;
fetchAndSwitchToThread(serverUrl, rootId);
}, [post.id, post.rootId, serverUrl]));
}, [blurAndDismissKeyboard, post.id, post.rootId, serverUrl]));
return (
<View

View file

@ -5,7 +5,8 @@ import AgentPost from '@agents/components/agent_post';
import {isAgentPost} from '@agents/utils';
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
import {Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
import {KeyboardController} from 'react-native-keyboard-controller';
import {removePost} from '@actions/local/post';
import {showPermalink} from '@actions/remote/permalink';
@ -17,7 +18,7 @@ import SystemAvatar from '@components/system_avatar';
import SystemHeader from '@components/system_header';
import {POST_TIME_TO_FAIL} from '@constants/post';
import * as Screens from '@constants/screens';
import {useHideExtraKeyboardIfNeeded} from '@context/extra_keyboard';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
@ -155,6 +156,7 @@ const Post = ({
const serverUrl = useServerUrl();
const theme = useTheme();
const isTablet = useIsTablet();
const {blurAndDismissKeyboard, closeInputAccessoryView, showInputAccessoryView} = useKeyboardAnimationContext();
const styles = getStyleSheet(theme);
const isAutoResponder = fromAutoResponder(post);
const isPendingOrFailed = isPostPendingOrFailed(post);
@ -185,7 +187,7 @@ const Post = ({
return false;
}, [customEmojiNames, post.message]);
const handlePostPress = useCallback(() => {
const handlePostPress = useCallback(async () => {
if ([Screens.SAVED_MESSAGES, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) {
showPermalink(serverUrl, '', post.id);
return;
@ -197,6 +199,7 @@ const Post = ({
} else if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) {
// BoR posts cannot have replies, so don't open threads screen for them
if (!borPost && [Screens.CHANNEL, Screens.PERMALINK].includes(location)) {
await blurAndDismissKeyboard();
const postRootId = post.rootId || post.id;
fetchAndSwitchToThread(serverUrl, postRootId);
}
@ -205,17 +208,19 @@ const Post = ({
setTimeout(() => {
pressDetected.current = false;
}, 300);
}, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost]);
}, [location, isAutoResponder, isSystemPost, isEphemeral, hasBeenDeleted, isPendingOrFailed, serverUrl, post, borPost, blurAndDismissKeyboard]);
const handlePress = useHideExtraKeyboardIfNeeded(() => {
const handlePress = useCallback(() => {
pressDetected.current = true;
KeyboardController.dismiss();
if (post) {
setTimeout(handlePostPress, 300);
}
}, [handlePostPress, post]);
const showPostOptions = useHideExtraKeyboardIfNeeded(() => {
const showPostOptions = useCallback(async () => {
if (!post) {
return;
}
@ -228,7 +233,11 @@ const Post = ({
return;
}
Keyboard.dismiss();
if (showInputAccessoryView) {
closeInputAccessoryView();
}
await blurAndDismissKeyboard();
const passProps = {sourceScreen: location, post, showAddReaction, serverUrl};
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
@ -239,11 +248,7 @@ const Post = ({
title,
props: passProps,
});
}, [
canDelete, hasBeenDeleted, intl,
isEphemeral, isPendingOrFailed, isTablet, isSystemPost,
location, post, serverUrl, showAddReaction, theme,
]);
}, [post, isSystemPost, canDelete, hasBeenDeleted, isPendingOrFailed, isEphemeral, blurAndDismissKeyboard, closeInputAccessoryView, showInputAccessoryView, location, showAddReaction, serverUrl, isTablet, intl, theme]);
const [, rerender] = useState(false);
useEffect(() => {

View file

@ -2,8 +2,8 @@
// See LICENSE.txt for license information.
import {act} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {DeviceEventEmitter, Platform} from 'react-native';
import React, {createRef, type ComponentProps} from 'react';
import {DeviceEventEmitter, type FlatList, Platform} from 'react-native';
import * as localPostFunctions from '@actions/local/post';
import * as postFunctions from '@actions/remote/post';
@ -75,11 +75,11 @@ describe('components/post_list/PostList', () => {
customEmojiNames: [],
lastViewedAt: 0,
location: Screens.CHANNEL,
nativeID: 'post-list',
posts: mockPosts,
savedPostIds: new Set(),
testID: 'post_list',
shouldShowJoinLeaveMessages: false,
listRef: createRef<FlatList<string | PostModel>>(),
};
it('renders correctly with basic props', () => {
@ -370,7 +370,7 @@ describe('components/post_list/PostList', () => {
// which causes the content offset to shift
act(() => {
flatList.props.onScroll({
flatList.props.onMomentumScrollEnd({
nativeEvent: {
contentOffset: {y: 200},
...unrelatedNativeEventsAttributes,
@ -383,7 +383,7 @@ describe('components/post_list/PostList', () => {
// if user post an image, scroll to bottom being called to push offset to 0, which causes the "New Messages" message to disappear
act(() => {
flatList.props.onScroll({
flatList.props.onMomentumScrollEnd({
nativeEvent: {
contentOffset: {y: 0},
...unrelatedNativeEventsAttributes,

View file

@ -3,8 +3,9 @@
import {FlatList} from '@stream-io/flat-list-mvcp';
import React, {type ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native';
import Animated, {type AnimatedStyle} from 'react-native-reanimated';
import {DeviceEventEmitter, type GestureResponderEvent, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native';
import {useKeyboardState} from 'react-native-keyboard-controller';
import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useSharedValue, type AnimatedStyle} from 'react-native-reanimated';
import {removePost} from '@actions/local/post';
import {fetchPosts, fetchPostThread} from '@actions/remote/post';
@ -15,8 +16,10 @@ import Post from '@components/post_list/post';
import ThreadOverview from '@components/post_list/thread_overview';
import {Events, Screens} from '@constants';
import {PostTypes} from '@constants/post';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {DEFAULT_INPUT_ACCESSORY_HEIGHT} from '@hooks/useInputAccessoryView';
import {getDateForDateLine, preparePostList} from '@utils/post_list';
import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config';
@ -42,7 +45,6 @@ type Props = {
isPostAcknowledgementEnabled?: boolean;
lastViewedAt: number;
location: AvailableScreens;
nativeID: string;
onEndReached?: () => void;
posts: PostModel[];
rootId?: string;
@ -55,6 +57,9 @@ type Props = {
testID: string;
currentCallBarVisible?: boolean;
savedPostIds: Set<string>;
listRef?: React.RefObject<FlatList<string | PostModel>>;
onTouchMove?: (event: GestureResponderEvent) => void;
onTouchEnd?: () => void;
}
type onScrollEndIndexListenerEvent = (endIndex: number) => void;
@ -96,7 +101,6 @@ const PostList = ({
isPostAcknowledgementEnabled,
lastViewedAt,
location,
nativeID,
onEndReached,
posts,
rootId,
@ -106,18 +110,60 @@ const PostList = ({
showNewMessageLine = true,
testID,
savedPostIds,
listRef,
onTouchMove,
onTouchEnd,
}: Props) => {
const firstIdInPosts = posts[0]?.id;
const listRef = useRef<FlatList<string | PostModel>>(null);
const {
keyboardTranslateY: keyboardHeightValue,
bottomInset: contentInset,
onScroll: onScrollProp,
postInputContainerHeight,
keyboardHeight,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
inputAccessoryViewAnimatedHeight,
isInputAccessoryViewMode,
} = useKeyboardAnimationContext();
const onScrollEndIndexListener = useRef<onScrollEndIndexListenerEvent>();
const onViewableItemsChangedListener = useRef<ViewableItemsChangedListenerEvent>();
const scrolledToHighlighted = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const [showScrollToEndBtn, setShowScrollToEndBtn] = useState(false);
const [lastPostId, setLastPostId] = useState<string | undefined>(firstIdInPosts);
const [progressViewOffset, setProgressViewOffset] = useState(postInputContainerHeight);
const [emojiPickerPadding, setEmojiPickerPadding] = useState(0);
const theme = useTheme();
const serverUrl = useServerUrl();
const {isVisible: isKeyboardVisible} = useKeyboardState();
// Update progressViewOffset to position RefreshControl correctly when keyboard-aware props are applied.
// Only update when keyboard state changes (fully open ↔ fully closed) to prevent flickering during animation.
const prevIsFullyOpen = useSharedValue(false);
const prevIsFullyClosed = useSharedValue(true);
useAnimatedReaction(
() => ({
isFullyOpen: isKeyboardFullyOpen.value,
isFullyClosed: isKeyboardFullyClosed.value,
keyboardTranslateY: keyboardHeightValue.value,
}),
({isFullyOpen, isFullyClosed, keyboardTranslateY}) => {
// Only update when state actually changes (transition detected)
const stateChanged = (prevIsFullyClosed.value !== isFullyClosed) || (prevIsFullyOpen.value !== isFullyOpen);
if (stateChanged && (isFullyOpen || isFullyClosed)) {
const offset = postInputContainerHeight + keyboardTranslateY;
runOnJS(setProgressViewOffset)(offset);
}
prevIsFullyOpen.value = isFullyOpen;
prevIsFullyClosed.value = isFullyClosed;
},
[postInputContainerHeight],
);
const orderedPosts = useMemo(() => {
return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, currentTimezone, location === Screens.THREAD, savedPostIds);
}, [posts, lastViewedAt, showNewMessageLine, currentUserId, currentUsername, shouldShowJoinLeaveMessages, currentTimezone, location, savedPostIds]);
@ -129,8 +175,16 @@ const PostList = ({
const isNewMessage = lastPostId ? firstIdInPosts !== lastPostId : false;
const scrollToEnd = useCallback(() => {
listRef.current?.scrollToOffset({offset: 0, animated: true});
}, []);
const activeHeight = Math.max(keyboardHeight.value, inputAccessoryViewAnimatedHeight.value);
const targetOffset = -activeHeight;
listRef?.current?.scrollToOffset({offset: targetOffset, animated: true});
setShowScrollToEndBtn(false);
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listRef]);
useEffect(() => {
const t = setTimeout(() => {
@ -182,15 +236,19 @@ const PostList = ({
}, [disablePullToRefresh, location, channelId, rootId, posts, serverUrl]);
const scrollToIndex = useCallback((index: number, animated = true, applyOffset = true) => {
listRef.current?.scrollToIndex({
if (index < 0 || !listRef?.current) {
return;
}
listRef.current.scrollToIndex({
animated,
index,
viewOffset: applyOffset ? Platform.select({ios: -45, default: 0}) : 0,
viewPosition: 1, // 0 is at bottom
});
}, []);
}, [listRef]);
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const internalOnScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const {y} = event.nativeEvent.contentOffset;
const isThresholdReached = y > CONTENT_OFFSET_THRESHOLD;
@ -332,7 +390,7 @@ const PostList = ({
scrolledToHighlighted.current = true;
// eslint-disable-next-line max-nested-callbacks
const index = orderedPosts.findIndex((p) => p.type === 'post' && p.value.currentPost.id === highlightedId);
if (index >= 0 && listRef.current) {
if (index >= 0 && listRef?.current) {
listRef.current?.scrollToIndex({
animated: true,
index,
@ -344,12 +402,51 @@ const PostList = ({
}, 500);
return () => clearTimeout(t);
// - listRef is a ref (stable reference, doesn't need to be in deps)
// - scrolledToHighlighted is a ref (stable reference, doesn't need to be in deps)
// - We only need to re-run when the posts list changes or the highlighted post changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderedPosts, highlightedId]);
// Sync emoji picker padding from SharedValue to React state
// This ensures the padding updates when SharedValues change
useAnimatedReaction(
() => {
const shouldAddEmojiPickerPadding = Platform.OS === 'android' && !isKeyboardVisible && isInputAccessoryViewMode.value;
const emojiPickerHeight = shouldAddEmojiPickerPadding ? (inputAccessoryViewAnimatedHeight.value || DEFAULT_INPUT_ACCESSORY_HEIGHT) : 0;
return emojiPickerHeight;
},
(emojiPickerHeight) => {
runOnJS(setEmojiPickerPadding)(emojiPickerHeight);
},
[isKeyboardVisible],
);
// Combine contentContainerStyle with padding style
// Use regular style with state value synced from SharedValues
const contentContainerStyleWithPadding = useMemo(() => {
const paddingStyle = {paddingTop: location === Screens.PERMALINK ? 0 : postInputContainerHeight + emojiPickerPadding};
return contentContainerStyle ? [contentContainerStyle, paddingStyle] : paddingStyle;
}, [location, postInputContainerHeight, emojiPickerPadding, contentContainerStyle]);
// contentInset only for dynamic keyboard height
const animatedProps = useAnimatedProps(
() => ({
contentInset: {
top: contentInset.value, // For inverted FlatList, applies to visual bottom
},
}),
[contentInset],
);
return (
<>
<Animated.FlatList
contentContainerStyle={contentContainerStyle}
animatedProps={animatedProps}
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior='never'
contentContainerStyle={contentContainerStyleWithPadding}
data={orderedPosts}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
@ -359,12 +456,13 @@ const PostList = ({
ListFooterComponent={footer}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={10}
nativeID={nativeID}
onEndReached={onEndReached}
onEndReachedThreshold={0.9}
onScroll={onScroll}
onScroll={onScrollProp}
onMomentumScrollEnd={internalOnScroll}
onScrollToIndexFailed={onScrollToIndexFailed}
onViewableItemsChanged={onViewableItemsChanged}
progressViewOffset={progressViewOffset}
ref={listRef}
removeClippedSubviews={true}
renderItem={renderItem}
@ -374,6 +472,8 @@ const PostList = ({
testID={`${testID}.flat_list`}
inverted={true}
refreshing={refreshing}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onRefresh={onRefresh}
/>
{location !== Screens.PERMALINK &&

View file

@ -3,14 +3,13 @@
import React, {useRef} from 'react';
import {useIntl} from 'react-intl';
import {Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle} from 'react-native';
import {Pressable, Text, View, type ViewStyle} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import {useTheme} from '@context/theme';
import {useIsTablet, useKeyboardHeight, useViewPosition} from '@hooks/device';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -25,7 +24,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
buttonStyle: {
position: 'absolute',
alignSelf: 'center',
bottom: -100,
bottom: 0,
flexDirection: 'row',
},
shadow: {
@ -65,6 +64,10 @@ type Props = {
testID?: string;
};
const SCROLL_TO_END_BOTTOM_OFFSET = 15;
const SCROLL_TO_END_BUTTON_WIDTH = 40;
const SCROLL_TO_END_BADGE_MAX_WIDTH = 169;
const ScrollToEndView = ({
onPress,
isNewMessage,
@ -74,36 +77,31 @@ const ScrollToEndView = ({
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const isTablet = useIsTablet();
const styles = getStyleFromTheme(theme);
const {keyboardTranslateY, postInputContainerHeight, inputAccessoryViewAnimatedHeight} = useKeyboardAnimationContext();
// On iOS we have to take account of the keyboard.
// We cannot use `useKeyboardOverlap` here because of the positioning of the element.
const guidingViewRef = useRef<View>(null);
const keyboardHeight = useKeyboardHeight();
const viewPosition = useViewPosition(guidingViewRef, []);
const dimensions = useWindowDimensions();
const bottomSpace = (dimensions.height - viewPosition);
const keyboardOverlap = Platform.select({ios: Math.max(0, keyboardHeight - bottomSpace), default: 0});
// Thread view on iPads has to take into account the insets
const insets = useSafeAreaInsets();
const shouldAdjustBottom = (Platform.OS === 'ios') && isTablet && (location === Screens.THREAD) && !keyboardHeight;
const bottomAdjustment = shouldAdjustBottom ? insets.bottom : 0;
const message = location === Screens.THREAD ? intl.formatMessage({id: 'postList.scrollToBottom.newReplies', defaultMessage: 'New replies'}) : intl.formatMessage({id: 'postList.scrollToBottom.newMessages', defaultMessage: 'New messages'});
const animatedStyle = useAnimatedStyle(
() => ({
transform: [
{
translateY: withTiming(showScrollToEndBtn ? -100 - keyboardOverlap - bottomAdjustment : -15, {duration: 300}),
},
],
maxWidth: withTiming(isNewMessage ? 169 : 40, {duration: 300}),
opacity: withTiming(showScrollToEndBtn ? 1 : 0),
}),
[showScrollToEndBtn, isNewMessage, keyboardOverlap, bottomAdjustment],
() => {
const activeHeight = Math.max(keyboardTranslateY.value, inputAccessoryViewAnimatedHeight.value);
return {
transform: [
{
translateY: showScrollToEndBtn ? -postInputContainerHeight - activeHeight - SCROLL_TO_END_BOTTOM_OFFSET : -SCROLL_TO_END_BOTTOM_OFFSET,
},
],
maxWidth: withTiming(isNewMessage ? SCROLL_TO_END_BADGE_MAX_WIDTH : SCROLL_TO_END_BUTTON_WIDTH, {duration: 300}),
opacity: withTiming(showScrollToEndBtn ? 1 : 0),
};
},
[showScrollToEndBtn, isNewMessage, keyboardTranslateY, inputAccessoryViewAnimatedHeight, postInputContainerHeight],
);
const scrollButtonStyles = isNewMessage ? styles.scrollToEndBadge : styles.scrollToEndButton;

View file

@ -14,6 +14,7 @@ type Props = {
category?: string;
name: string;
onEmojiPress: (emoji: string) => void;
preventDoubleTap?: boolean;
size?: number;
style?: StyleProp<ViewStyle>;
}
@ -22,14 +23,16 @@ const CATEGORIES_WITH_SKINS = ['people-body'];
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const TouchableEmoji = ({category, name, onEmojiPress, size = 30, style}: Props) => {
const onPress = usePreventDoubleTap(useCallback(() => onEmojiPress(name), [name, onEmojiPress]));
const TouchableEmoji = ({category, name, onEmojiPress, preventDoubleTap = true, size = 30, style}: Props) => {
const handlePress = useCallback(() => onEmojiPress(name), [name, onEmojiPress]);
const onPress = preventDoubleTap ? usePreventDoubleTap(handlePress) : handlePress;
if (category && CATEGORIES_WITH_SKINS.includes(category)) {
return (
<SkinnedEmoji
name={name}
onEmojiPress={onEmojiPress}
preventDoubleTap={preventDoubleTap}
size={size}
style={style}
/>

View file

@ -14,13 +14,14 @@ import {isValidNamedEmoji} from '@utils/emoji/helpers';
type Props = {
name: string;
onEmojiPress: (emoji: string) => void;
preventDoubleTap?: boolean;
size?: number;
style?: StyleProp<ViewStyle>;
}
const hitSlop = {top: 10, bottom: 10, left: 10, right: 10};
const SkinnedEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
const SkinnedEmoji = ({name, onEmojiPress, preventDoubleTap = true, size = 30, style}: Props) => {
const skinTone = useEmojiSkinTone();
const emojiName = useMemo(() => {
const skinnedEmoji = `${name}_${skinCodes[skinTone]}`;
@ -30,9 +31,10 @@ const SkinnedEmoji = ({name, onEmojiPress, size = 30, style}: Props) => {
return skinnedEmoji;
}, [name, skinTone]);
const onPress = usePreventDoubleTap(useCallback(() => {
const handlePress = useCallback(() => {
onEmojiPress(emojiName);
}, [emojiName, onEmojiPress]));
}, [emojiName, onEmojiPress]);
const onPress = preventDoubleTap ? usePreventDoubleTap(handlePress) : handlePress;
return (
<View

View file

@ -37,4 +37,7 @@ export default keyMirror({
ACTIVE_SCREEN: null,
ACTIVE_SERVER_CHANGED: null,
FILE_ADD_REMOVED: null,
KEYBOARD_STATE_CHANGED: null,
CLOSE_INPUT_ACCESSORY_VIEW: null,
EMOJI_PICKER_SEARCH_FOCUSED: null,
});

View file

@ -6,6 +6,7 @@ import PLAYBOOKS_SCREENS from '@playbooks/constants/screens';
export const ABOUT = 'About';
export const ACCOUNT = 'Account';
export const APPS_FORM = 'AppForm';
export const ATTACHMENT_OPTIONS = 'AttachmentOptions';
export const BOTTOM_SHEET = 'BottomSheet';
export const BROWSE_CHANNELS = 'BrowseChannels';
export const CALL = 'Call';
@ -94,6 +95,7 @@ export default {
ABOUT,
ACCOUNT,
APPS_FORM,
ATTACHMENT_OPTIONS,
BOTTOM_SHEET,
BROWSE_CHANNELS,
CALL,
@ -206,6 +208,7 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
]);
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
ATTACHMENT_OPTIONS,
BOTTOM_SHEET,
DRAFT_SCHEDULED_POST_OPTIONS,
EMOJI_PICKER,

View file

@ -0,0 +1,171 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {createContext, useContext, useMemo, useRef, useCallback, type ReactNode} from 'react';
import {useSharedValue, type SharedValue} from 'react-native-reanimated';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
interface KeyboardAnimationContextType {
keyboardTranslateY: SharedValue<number>;
bottomInset: SharedValue<number>;
scrollOffset: SharedValue<number>;
keyboardHeight: SharedValue<number>;
scrollPosition: SharedValue<number>;
onScroll: (event: unknown) => void;
postInputContainerHeight: number;
inputRef: React.MutableRefObject<PasteInputRef | undefined>;
blurInput: () => void;
focusInput: () => void;
blurAndDismissKeyboard: () => Promise<void>;
isKeyboardFullyOpen: SharedValue<boolean>;
isKeyboardFullyClosed: SharedValue<boolean>;
isKeyboardInTransition: SharedValue<boolean>;
showInputAccessoryView: boolean;
isInputAccessoryViewMode: SharedValue<boolean>;
setShowInputAccessoryView: (show: boolean) => void;
lastKeyboardHeight: number;
inputAccessoryViewAnimatedHeight: SharedValue<number>;
isTransitioningFromCustomView: SharedValue<boolean>;
closeInputAccessoryView: () => void;
scrollToEnd: () => void;
isEmojiSearchFocused: boolean;
setIsEmojiSearchFocused: (focused: boolean) => void;
cursorPositionRef: React.MutableRefObject<number>;
registerCursorPosition: (cursorPosition: number) => void;
updateValue: React.Dispatch<React.SetStateAction<string>> | null;
updateCursorPosition: React.Dispatch<React.SetStateAction<number>> | null;
registerPostInputCallbacks: (
updateValueFn: React.Dispatch<React.SetStateAction<string>>,
updateCursorPositionFn: React.Dispatch<React.SetStateAction<number>>
) => void;
}
const KeyboardAnimationContext = createContext<KeyboardAnimationContextType | null>(null);
export const KeyboardAnimationProvider = ({
children,
value,
}: {
children: ReactNode;
value: KeyboardAnimationContextType;
}) => {
return (
<KeyboardAnimationContext.Provider value={value}>
{children}
</KeyboardAnimationContext.Provider>
);
};
const DEFAULT_POST_INPUT_HEIGHT = 91;
export const useKeyboardAnimationContext = () => {
const context = useContext(KeyboardAnimationContext);
// Always create default values (hooks must be called unconditionally)
const defaultKeyboardTranslateY = useSharedValue(0);
const defaultBottomInset = useSharedValue(0);
const defaultScrollOffset = useSharedValue(0);
const defaultKeyboardHeight = useSharedValue(0);
const defaultScrollPosition = useSharedValue(0);
const defaultIsKeyboardFullyOpen = useSharedValue(false);
const defaultIsKeyboardFullyClosed = useSharedValue(true);
const defaultIsKeyboardInTransition = useSharedValue(false);
const defaultInputRef = useRef<PasteInputRef | undefined>(undefined);
const defaultIsInputAccessoryViewMode = useSharedValue(false);
const defaultIsTransitioningFromCustomView = useSharedValue(false);
const defaultInputAccessoryViewAnimatedHeight = useSharedValue(0);
const defaultOnScroll = useCallback(() => {
// No-op fallback
}, []);
const defaultBlurInput = useCallback(() => {
// No-op fallback
}, []);
const defaultFocusInput = useCallback(() => {
// No-op fallback
}, []);
const defaultBlurAndDismissKeyboard = useCallback(async () => {
// No-op fallback
}, []);
const defaultSetShowInputAccessoryView = useCallback(() => {
// No-op fallback
}, []);
const defaultCloseInputAccessoryView = useCallback(() => {
// No-op fallback
}, []);
const defaultScrollToEnd = useCallback(() => {
// No-op fallback
}, []);
const defaultSetIsEmojiSearchFocused = useCallback(() => {
// No-op fallback
}, []);
const defaultCursorPositionRef = useRef<number>(0);
const defaultRegisterCursorPosition = useCallback(() => {
// No-op fallback
}, []);
const defaultUpdateValue = useRef<React.Dispatch<React.SetStateAction<string>> | null>(null);
const defaultUpdateCursorPosition = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
const defaultRegisterPostInputCallbacks = useCallback(() => {
// No-op fallback
}, []);
const fallbackValue = useMemo(() => ({
keyboardTranslateY: defaultKeyboardTranslateY,
bottomInset: defaultBottomInset,
scrollOffset: defaultScrollOffset,
keyboardHeight: defaultKeyboardHeight,
scrollPosition: defaultScrollPosition,
onScroll: defaultOnScroll,
postInputContainerHeight: DEFAULT_POST_INPUT_HEIGHT,
inputRef: defaultInputRef,
blurInput: defaultBlurInput,
focusInput: defaultFocusInput,
blurAndDismissKeyboard: defaultBlurAndDismissKeyboard,
isKeyboardFullyOpen: defaultIsKeyboardFullyOpen,
isKeyboardFullyClosed: defaultIsKeyboardFullyClosed,
isKeyboardInTransition: defaultIsKeyboardInTransition,
setShowInputAccessoryView: defaultSetShowInputAccessoryView,
showInputAccessoryView: false,
lastKeyboardHeight: 0,
isInputAccessoryViewMode: defaultIsInputAccessoryViewMode,
inputAccessoryViewAnimatedHeight: defaultInputAccessoryViewAnimatedHeight,
isTransitioningFromCustomView: defaultIsTransitioningFromCustomView,
closeInputAccessoryView: defaultCloseInputAccessoryView,
scrollToEnd: defaultScrollToEnd,
isEmojiSearchFocused: false,
setIsEmojiSearchFocused: defaultSetIsEmojiSearchFocused,
cursorPositionRef: defaultCursorPositionRef,
registerCursorPosition: defaultRegisterCursorPosition,
updateValue: defaultUpdateValue.current,
updateCursorPosition: defaultUpdateCursorPosition.current,
registerPostInputCallbacks: defaultRegisterPostInputCallbacks,
// Shared values don't need to be in dependencies - they're stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [
defaultOnScroll,
defaultInputRef,
defaultBlurInput,
defaultFocusInput,
defaultBlurAndDismissKeyboard,
defaultSetShowInputAccessoryView,
defaultCloseInputAccessoryView,
defaultScrollToEnd,
defaultSetIsEmojiSearchFocused,
defaultCursorPositionRef,
defaultRegisterCursorPosition,
defaultUpdateValue,
defaultUpdateCursorPosition,
defaultRegisterPostInputCallbacks,
]);
// If context exists, return it; otherwise return fallback
return context || fallbackValue;
};

View file

@ -0,0 +1,490 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act} from '@testing-library/react-hooks';
import {useKeyboardHandler} from 'react-native-keyboard-controller';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
import {useKeyboardAnimation} from './keyboardAnimation';
jest.mock('react-native-keyboard-controller', () => ({
useKeyboardHandler: jest.fn(),
}));
jest.mock('react-native-reanimated', () => {
const sharedValues = new Map();
return {
...jest.requireActual('react-native-reanimated/mock'),
useSharedValue: jest.fn((initial) => {
const key = Math.random();
const sv = {
value: initial,
_key: key,
};
sharedValues.set(key, sv);
return sv;
}),
useDerivedValue: jest.fn((fn) => ({
value: fn(),
})),
useAnimatedScrollHandler: jest.fn((handlers) => (event: {contentOffset: {y: number}}) => {
if (handlers.onScroll) {
handlers.onScroll(event);
}
}),
};
});
describe('useKeyboardAnimation', () => {
const mockUseKeyboardHandler = useKeyboardHandler as jest.Mock;
let keyboardHandlerCallbacks: {
onStart?: (e: {height: number; progress: number}) => void;
onInteractive?: (e: {height: number; progress: number}) => void;
onMove?: (e: {height: number; progress: number}) => void;
onEnd?: (e: {height: number; progress: number}) => void;
} = {};
beforeEach(() => {
jest.clearAllMocks();
keyboardHandlerCallbacks = {};
mockUseKeyboardHandler.mockImplementation((callbacks) => {
keyboardHandlerCallbacks = callbacks;
});
});
describe('initialization', () => {
it('should initialize with default values', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
expect(result.current.keyboardTranslateY.value).toBe(0);
expect(result.current.bottomInset.value).toBe(0);
expect(result.current.scrollOffset.value).toBe(0);
expect(result.current.scrollPosition.value).toBe(0);
expect(result.current.keyboardHeight.value).toBe(0);
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
expect(result.current.isKeyboardFullyClosed.value).toBe(true);
expect(result.current.isKeyboardInTransition.value).toBe(false);
expect(result.current.onScroll).toBeDefined();
});
it('should call useKeyboardHandler with callbacks', () => {
renderHook(() => useKeyboardAnimation(100, true, false, 0, false, true));
expect(mockUseKeyboardHandler).toHaveBeenCalled();
expect(keyboardHandlerCallbacks.onStart).toBeDefined();
expect(keyboardHandlerCallbacks.onInteractive).toBeDefined();
expect(keyboardHandlerCallbacks.onMove).toBeDefined();
expect(keyboardHandlerCallbacks.onEnd).toBeDefined();
});
});
describe('enabled prop', () => {
it('should skip processing when enabled is false', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, false),
);
const initialHeight = result.current.keyboardTranslateY.value;
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
});
it('should process events when enabled is true', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(300);
});
});
describe('enableAnimation prop', () => {
it('should skip processing when enableAnimation is false', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, false, false, 0, false, true),
);
const initialHeight = result.current.keyboardTranslateY.value;
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
});
});
describe('onStart callback', () => {
it('should update keyboard height and state on start', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardHeight.value).toBe(300);
expect(result.current.keyboardTranslateY.value).toBe(300);
expect(result.current.bottomInset.value).toBe(300);
// isKeyboardFullyOpen is set to false in onStart to prevent jerky behavior
// It will be set to true in onEnd when animation completes
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
expect(result.current.isKeyboardFullyClosed.value).toBe(false);
expect(result.current.isKeyboardInTransition.value).toBe(false);
// Call onEnd to finalize the state
act(() => {
keyboardHandlerCallbacks.onEnd?.({
height: 300,
progress: 1,
});
});
// After onEnd, keyboard should be marked as fully open
expect(result.current.isKeyboardFullyOpen.value).toBe(true);
expect(result.current.isKeyboardFullyClosed.value).toBe(false);
expect(result.current.isKeyboardInTransition.value).toBe(false);
});
it('should handle partial progress on start', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 150,
progress: 0.5,
});
});
expect(result.current.keyboardTranslateY.value).toBe(150);
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
expect(result.current.isKeyboardInTransition.value).toBe(true);
});
it('should ignore adjustment events from KeyboardGestureArea', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
// Set initial keyboard height
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
const initialHeight = result.current.keyboardTranslateY.value;
// Try to trigger adjustment event (height = keyboardHeight - postInputContainerHeight)
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 200, // 300 - 100
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
});
});
describe('onInteractive callback', () => {
it('should update values during interactive drag', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
// First call onStart to set initial height (required for onInteractive to work)
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
// Then call onInteractive to simulate interactive drag
act(() => {
keyboardHandlerCallbacks.onInteractive?.({
height: 250,
progress: 0.8,
});
});
expect(result.current.keyboardTranslateY.value).toBe(250);
expect(result.current.scrollOffset.value).toBe(250);
expect(result.current.bottomInset.value).toBe(250);
});
it('should track keyboard closing state', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
// Set initial height
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
// Simulate closing (height decreasing)
act(() => {
keyboardHandlerCallbacks.onInteractive?.({
height: 200,
progress: 0.7,
});
});
// isKeyboardClosing is internal state, not exposed in return value
// We verify closing behavior by checking that height decreases
expect(result.current.keyboardTranslateY.value).toBeLessThan(300);
});
it('should ignore events matching postInputContainerHeight', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
const initialHeight = result.current.keyboardTranslateY.value;
act(() => {
keyboardHandlerCallbacks.onInteractive?.({
height: 100, // matches postInputContainerHeight
progress: 0.5,
});
});
expect(result.current.keyboardTranslateY.value).toBe(initialHeight);
});
});
describe('onMove callback', () => {
it('should update values during keyboard movement', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 250,
progress: 0.7,
});
});
act(() => {
keyboardHandlerCallbacks.onMove?.({
height: 280,
progress: 0.9,
});
});
expect(result.current.keyboardTranslateY.value).toBe(280);
expect(result.current.scrollOffset.value).toBe(280);
expect(result.current.bottomInset.value).toBe(280);
});
it('should ignore onMove events when keyboard is closing', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
act(() => {
keyboardHandlerCallbacks.onInteractive?.({
height: 200,
progress: 0.7,
});
});
act(() => {
keyboardHandlerCallbacks.onMove?.({
height: 150,
progress: 0.5,
});
});
expect(result.current.keyboardTranslateY.value).toBe(200);
expect(result.current.scrollOffset.value).toBe(200);
expect(result.current.bottomInset.value).toBe(200);
});
it('should handle negative heights from programmatic dismiss', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onMove?.({
height: -50, // negative height from KeyboardController.dismiss()
progress: 0,
});
});
expect(result.current.keyboardTranslateY.value).toBe(50); // Math.abs applied
});
});
describe('onEnd callback', () => {
it('should finalize state when keyboard fully opens', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
act(() => {
keyboardHandlerCallbacks.onEnd?.({
height: 300,
progress: 1,
});
});
expect(result.current.isKeyboardFullyOpen.value).toBe(true);
expect(result.current.isKeyboardFullyClosed.value).toBe(false);
expect(result.current.isKeyboardInTransition.value).toBe(false);
});
it('should finalize state when keyboard fully closes', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 0,
progress: 0,
});
});
act(() => {
keyboardHandlerCallbacks.onEnd?.({
height: 0,
progress: 0,
});
});
expect(result.current.isKeyboardFullyOpen.value).toBe(false);
expect(result.current.isKeyboardFullyClosed.value).toBe(true);
expect(result.current.isKeyboardInTransition.value).toBe(false);
expect(result.current.keyboardTranslateY.value).toBe(0);
});
});
describe('tablet and tab bar adjustment', () => {
it('should apply tab bar adjustment for tablets in non-thread view', () => {
const safeAreaBottom = 20;
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, true, safeAreaBottom, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
const expectedAdjustment = BOTTOM_TAB_HEIGHT + safeAreaBottom;
expect(result.current.keyboardTranslateY.value).toBe(300 - expectedAdjustment);
});
it('should apply safeAreaBottom adjustment for thread view', () => {
const safeAreaBottom = 20;
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, true, safeAreaBottom, true, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(300);
});
it('should apply safeAreaBottom adjustment for mobile', () => {
const safeAreaBottom = 20;
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, safeAreaBottom, false, true),
);
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 300,
progress: 1,
});
});
expect(result.current.keyboardTranslateY.value).toBe(300 - safeAreaBottom);
});
});
describe('scroll handler', () => {
it('should track scroll position with bottomInset', () => {
const {result} = renderHook(() =>
useKeyboardAnimation(100, true, false, 0, false, true),
);
// Set bottomInset first
act(() => {
keyboardHandlerCallbacks.onStart?.({
height: 200,
progress: 1,
});
});
const mockScrollEvent = {
contentOffset: {y: 100},
} as unknown as Parameters<typeof result.current.onScroll>[0];
act(() => {
result.current.onScroll(mockScrollEvent);
});
expect(result.current.scrollPosition.value).toBe(100 + result.current.bottomInset.value);
});
});
});

View file

@ -0,0 +1,525 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useKeyboardHandler} from 'react-native-keyboard-controller';
import {useAnimatedScrollHandler, useDerivedValue, useSharedValue} from 'react-native-reanimated';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
export const useKeyboardAnimation = (
postInputContainerHeight: number,
enableAnimation = true,
isTablet = false,
safeAreaBottom = 0,
isThreadView = false,
enabled = true,
) => {
/**
* progress: Keyboard animation progress (0 = closed, 1 = fully open)
* Used for: Tracking keyboard animation state
*/
const progress = useSharedValue(0);
/**
* keyboardTranslateY: Keyboard height (adjusted for tab bar) used to animate input container position
*
* How it works: This value represents the keyboard height and is used with negative translateY
* in useKeyboardAwarePostDraft.ts (transform: [{translateY: -keyboardTranslateY.value}]) to move the input
* container UP by the keyboard height amount. Higher keyboard height = input moves up more.
*
* Smoothed during interactive gestures to prevent jerky movements
*/
const keyboardTranslateY = useSharedValue(0);
/**
* bottomInset: Bottom inset for the scroll view
* Adds padding at the bottom of scroll content so it doesn't hide behind keyboard
*/
const bottomInset = useSharedValue(0);
/**
* scrollOffset: Scroll offset adjustment when keyboard opens
* Ensures the scroll view scrolls to the right position when keyboard appears
*/
const scrollOffset = useSharedValue(0);
/**
* scroll: Tracks the current scroll position of the ScrollView
* Used to calculate proper offset when keyboard opens
*/
const scrollPosition = useSharedValue(0);
/**
* keyboardHeight: The exact height of the keyboard from events
* Always reflects the true keyboard height without smoothing
*/
const keyboardHeight = useSharedValue(0);
/**
* isKeyboardClosing: Tracks if keyboard is currently closing (detected in onInteractive)
* Used to prevent height jumps in onMove when user releases finger mid-swipe
*/
const isKeyboardClosing = useSharedValue(false);
/**
* isInteractiveGesture: Tracks if we're in an interactive gesture (user touching keyboard)
* Used to: Distinguish between normal keyboard opening vs stale events after gesture
*/
const isInteractiveGesture = useSharedValue(false);
/**
* isKeyboardFullyOpen: True when keyboard is fully open (height > 0 and progress === 1)
*/
const isKeyboardFullyOpen = useSharedValue(false);
/**
* isKeyboardFullyClosed: True when keyboard is fully closed (height === 0)
*/
const isKeyboardFullyClosed = useSharedValue(true);
/**
* isKeyboardInTransition: True when keyboard is animating between open/closed states
*/
const isKeyboardInTransition = useSharedValue(false);
/**
* isEnabled: Shared value to track if keyboard handling is enabled for this screen
* Used to prevent processing keyboard events when screen is not visible
* useDerivedValue automatically tracks the enabled prop and updates reactively
*/
const isEnabled = useDerivedValue(() => enabled, [enabled]);
// Calculate tab bar adjustment (only for tablets, not in thread view)
// This accounts for the tab bar height + safe area bottom that gets hidden when keyboard opens
// Thread views don't have a tab bar, so no adjustment is needed
// For tablets in thread view, also don't subtract safeAreaBottom since the input container
// should move up by the full keyboard height to clear the keyboard completely
let tabBarAdjustment: number;
if (isTablet && !isThreadView) {
// Channel view on tablet: account for tab bar + safe area
tabBarAdjustment = BOTTOM_TAB_HEIGHT + safeAreaBottom;
} else if (isTablet && isThreadView) {
// Thread view on tablet: no adjustment needed (no tab bar, full keyboard height)
tabBarAdjustment = 0;
} else {
// Mobile devices: account for safe area only
tabBarAdjustment = safeAreaBottom;
}
/**
* isInputAccessoryViewMode: Whether we're showing input accessory view (emoji picker) instead of keyboard
* When true, keyboard handlers are ignored to prevent interference with the emoji picker
*/
const isInputAccessoryViewMode = useSharedValue(false);
/**
* isTransitioningFromCustomView: Special mode when transitioning from custom view to keyboard
* Prevents height updates during the transition to avoid jumps
*/
const isTransitioningFromCustomView = useSharedValue(false);
// ------------------------------------------------------------------
// KEYBOARD EVENT HANDLERS
// ------------------------------------------------------------------
/**
* useKeyboardHandler: Subscribe to keyboard lifecycle events
*
* IMPORTANT: All callbacks here are WORKLETS (run on UI thread)
* The "worklet" directive tells Reanimated to compile this function for the UI thread
* This enables 60fps smooth animations without JavaScript thread delays
*
* Event lifecycle:
* 1. onStart: Keyboard animation begins
* 2. onInteractive: User is dragging keyboard interactively
* 3. onMove: Keyboard position changes during animation
* 4. onEnd: Keyboard animation completes
*/
useKeyboardHandler({
/**
* onStart: Called when keyboard animation starts
* @param e - Event object with keyboard information
*/
onStart: (e) => {
'worklet';
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
return;
}
// Ignore keyboard events during transition from custom view to keyboard
if (isTransitioningFromCustomView.value) {
return;
}
// Skip processing if screen is not enabled/visible or if animations are disabled
if (!isEnabled.value || !enableAnimation) {
return;
}
// Ignore adjustment event from KeyboardGestureArea (fires after keyboard fully opens)
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
// We use both offset prop AND manual animation, so this would cause flicker
// Example: keyboard opens at 346px → then adjustment event at 229px (346 - 117 offset)
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
return;
}
progress.value = e.progress;
// Store the exact keyboard height
keyboardHeight.value = e.height;
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
// CRITICAL FIX: On real iOS devices, onStart can fire with progress: 1 before animation completes
// Even if progress is 1, we should NOT set isKeyboardFullyOpen to true in onStart because:
// 1. onMove events may still come with lower progress values
// 2. Only onEnd with progress === 1 should mark the keyboard as fully open
// This prevents jerky behavior where the input container jumps down and back up
const wasAlreadyOpen = keyboardTranslateY.value > 0;
// Always treat as transitioning if we might receive more events (even with progress: 1)
// Only exception: if height is 0 (keyboard closing)
const shouldTreatAsTransitioning = e.height > 0 && (e.progress < 1 || wasAlreadyOpen);
keyboardTranslateY.value = adjustedHeight;
// Update keyboard state flags
// NEVER mark as fully open in onStart - always wait for onEnd to confirm
isKeyboardFullyClosed.value = e.height === 0;
isKeyboardFullyOpen.value = false; // Always false in onStart - onEnd will set it correctly
isKeyboardInTransition.value = e.height > 0 && shouldTreatAsTransitioning;
// When keyboard closes (height: 0), preserve isKeyboardClosing flag if it was true
// This prevents onMove events from processing stale closing animation values
// Only reset if keyboard was actually open (not already closing)
if (e.height === 0) {
// If keyboard was closing, keep the flag true so onMove knows to ignore stale events
// If keyboard was opening, reset the flag
if (!isKeyboardClosing.value) {
isKeyboardClosing.value = false;
}
// Always reset interactive gesture flag when keyboard closes
isInteractiveGesture.value = false;
}
// Update scroll view insets and offsets
// bottomInset: Adds bottom padding to scroll content
bottomInset.value = adjustedHeight;
// CRITICAL FIX: Don't update scrollOffset in onStart if progress === 1 and keyboard wasn't already open
// On real iOS devices, onStart can fire with progress: 1 BEFORE the animation starts
// This causes scrollOffset to jump immediately to the final value, triggering a scroll
// Then onMove events come with intermediate values, causing jerky "down then up" behavior
// Solution: Only update scrollOffset in onStart if progress < 1 OR keyboard was already open
// Let onMove handle the smooth animation for opening keyboards
// Note: wasAlreadyOpen is checked BEFORE keyboardTranslateY.value is updated, so use that
if (e.progress < 1 || wasAlreadyOpen || e.height === 0) {
// scrollOffset: Ensures the scroll view scrolls with the keyboard animation
scrollOffset.value = adjustedHeight;
} else {
// Don't update scrollOffset yet - wait for onMove to animate smoothly
// This prevents the initial jump when keyboard opens
}
},
/**
* onInteractive: Called continuously while user drags keyboard interactively
* This provides smooth real-time updates as the user swipes
*
* @param e - Event object with keyboard information
*/
onInteractive: (e) => {
'worklet';
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
return;
}
// Ignore keyboard events during transition from custom view to keyboard
if (isTransitioningFromCustomView.value) {
return;
}
// On Android, use native keyboard behavior (no custom animations)
if (!isEnabled.value || !enableAnimation) {
return;
}
if (parseInt(e.height.toString()) === postInputContainerHeight) {
return;
}
// Ignore stale interactive events during screen navigation
// When navigating from channel (keyboard open) to thread, the channel's keyboard dismissal
// can trigger stale onInteractive events that arrive at the newly mounted thread screen.
// If keyboard is fully closed (keyboardTranslateY === 0), any onInteractive event is stale and should be ignored.
if (keyboardTranslateY.value === 0) {
return;
}
const previousKeyboardTranslateY = keyboardTranslateY.value;
if (keyboardTranslateY.value > 0) {
isInteractiveGesture.value = true;
}
// Calculate adjusted height first to compare properly
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
// Track if keyboard is closing (adjusted height decreasing) or opening (adjusted height increasing)
// Compare adjusted heights, not raw event height vs adjusted height
// This detects direction changes mid-gesture for smooth animations
if (adjustedHeight < previousKeyboardTranslateY) {
// Keyboard is closing - height is decreasing
isKeyboardClosing.value = true;
} else if (adjustedHeight > previousKeyboardTranslateY) {
// User changed direction - swiped back up
isKeyboardClosing.value = false;
}
// If adjustedHeight === previousKeyboardTranslateY, keep current isKeyboardClosing state
keyboardTranslateY.value = adjustedHeight;
scrollOffset.value = adjustedHeight;
bottomInset.value = adjustedHeight;
// Update keyboard state flags
isKeyboardFullyClosed.value = e.height === 0;
isKeyboardFullyOpen.value = e.height > 0 && e.progress === 1;
isKeyboardInTransition.value = e.height > 0 && e.progress < 1;
},
/**
* onMove: Called continuously as keyboard animates (programmatic or gesture)
* Similar to onInteractive but for all keyboard movements
*
* @param e - Event object with keyboard information
*/
onMove: (e) => {
'worklet';
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
return;
}
// During transition from custom view, don't update keyboardHeight from onMove events
// These events can be stale/out-of-order. Wait for onEnd to get the final correct height.
if (isTransitioningFromCustomView.value) {
return;
}
// On Android, use native keyboard behavior (no custom animations)
if (!isEnabled.value || !enableAnimation) {
return;
}
// CRITICAL FIX: Ignore stale onMove events after keyboard closes
// When keyboard closes, onStart fires with height: 0, but onMove may still fire with stale values
// If keyboardHeight is 0 (closed), ignore any onMove events that try to set positive heights
if (keyboardHeight.value === 0 && e.height > 0) {
return;
}
// If keyboard is closing (detected in onInteractive), ignore onMove events
// This prevents stale closing animation values from causing jumps
if (isKeyboardClosing.value) {
return;
}
// Ignore adjustment event from KeyboardGestureArea (fires after keyboard fully opens)
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
// We use both offset prop AND manual animation, so this would cause flicker
// Example: keyboard opens at 346px → then adjustment event at 229px (346 - 117 offset)
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
return;
}
// CRITICAL FIX: Ignore invalid onMove events with progress > 1
// On real iOS devices, sometimes onMove fires with progress > 1 (e.g., 1.266)
// These are invalid events that can cause the input container to jump
// Progress should always be between 0 and 1 for valid keyboard animation events
if (e.progress > 1) {
return;
}
const absHeight = Math.abs(e.height) - (tabBarAdjustment * e.progress); // Use Math.abs because programmatic dismiss (KeyboardController.dismiss()) reports negative heights
// CRITICAL FIX: On real iOS devices, onStart can fire with progress: 1 before animation completes
// Then onMove events come with lower heights/progress, causing jerky behavior
// If we've already reached the final keyboard height (isKeyboardFullyOpen or keyboardHeight matches final),
// ignore onMove events that would reduce keyboardTranslateY below the final value
// BUT: Always allow scrollOffset and bottomInset to update during animation for smooth scrolling
const finalHeight = keyboardHeight.value;
const finalAdjustedHeight = finalHeight > 0 ? finalHeight - tabBarAdjustment : 0;
const hasReachedFinalState = finalHeight > 0 && keyboardTranslateY.value >= finalAdjustedHeight * 0.95; // 95% threshold to account for rounding
if (hasReachedFinalState && absHeight < keyboardTranslateY.value && e.progress < 1) {
// Still update scrollOffset and bottomInset even if we ignore keyboardTranslateY
// This ensures the list scrolls smoothly with the keyboard animation
scrollOffset.value = absHeight;
bottomInset.value = absHeight;
return;
}
// Ignore stale/incorrect events ONLY during/after interactive gestures
// After user releases finger mid-swipe up, onMove sometimes gets wrong height values
// Example: keyboard at 346px, user releases, onMove reports 80px (stale from earlier)
// This check only applies during interactive gestures, not during normal keyboard opening
if (isInteractiveGesture.value && absHeight < keyboardTranslateY.value && e.progress < 1) {
return;
}
keyboardTranslateY.value = absHeight;
scrollOffset.value = absHeight;
bottomInset.value = absHeight;
// Update keyboard state flags
isKeyboardFullyClosed.value = absHeight === 0;
isKeyboardFullyOpen.value = absHeight > 0 && e.progress === 1;
isKeyboardInTransition.value = absHeight > 0 && e.progress < 1;
},
onEnd: (e) => {
'worklet';
// Skip processing if screen is not enabled/visible or if animations are disabled
if (!isEnabled.value) {
return;
}
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
isKeyboardClosing.value = false;
isTransitioningFromCustomView.value = false;
return;
}
// On Android, use native keyboard behavior (no custom animations)
if (!enableAnimation) {
isKeyboardClosing.value = false;
isTransitioningFromCustomView.value = false;
return;
}
// Ignore adjustment event from KeyboardGestureArea (can fire in onEnd too)
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
// Example: keyboard opens at 346px → then adjustment event at 255px (346 - 91 offset)
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
return;
}
// Store if we were transitioning from custom view before clearing the flag
const wasTransitioningFromCustomView = isTransitioningFromCustomView.value;
// Reset state flags
isKeyboardClosing.value = false;
isTransitioningFromCustomView.value = false;
isInteractiveGesture.value = false;
// Use e.progress (from event) not progress.value (shared value might be stale)
if (e.progress === 1) {
// CRITICAL FIX: Update keyboardHeight FIRST from onEnd event (most reliable source)
// During transition, onMove events can be stale/out-of-order, so onEnd's height is authoritative
// Store previous value for stale check before updating
const previousKeyboardHeight = keyboardHeight.value;
keyboardHeight.value = e.height;
// Use same calculation as onInteractive/onMove for consistency
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
// Ignore stale/out-of-order events
// If keyboard is supposed to be closed (previousKeyboardHeight = 0) but we get an open event,
// it's a stale event from before the close - ignore it
if (previousKeyboardHeight === 0 && e.height > 0) {
return;
}
// If transitioning from custom view, always update keyboardTranslateY to match keyboard
// This ensures correct positioning when emoji picker height != keyboard height
if (wasTransitioningFromCustomView) {
keyboardTranslateY.value = adjustedHeight;
} else if (Math.abs(keyboardTranslateY.value - adjustedHeight) > 1) {
// For normal keyboard opening, only adjust if significantly different
keyboardTranslateY.value = adjustedHeight;
}
// Update bottomInset and scrollOffset to match final keyboard height
// This ensures messages don't get hidden behind keyboard
bottomInset.value = adjustedHeight;
scrollOffset.value = adjustedHeight;
isKeyboardFullyOpen.value = true;
isKeyboardFullyClosed.value = false;
isKeyboardInTransition.value = false;
}
// Use e.progress (from event) not progress.value (shared value might be stale)
if (e.progress === 0) {
// Only set to 0 if not already close to 0
if (Math.abs(keyboardTranslateY.value) > 0.5) {
keyboardTranslateY.value = 0;
}
isKeyboardFullyOpen.value = false;
isKeyboardFullyClosed.value = true;
isKeyboardInTransition.value = false;
scrollOffset.value = 0;
// CRITICAL FIX: Reset scrollPosition when keyboard closes
// scrollPosition = contentOffsetY + bottomInset
// Before bottomInset becomes 0, calculate actual content offset: scrollPosition - bottomInset
// This preserves the user's scroll position even if iOS resets the list to 0
const currentBottomInset = bottomInset.value;
if (scrollPosition.value > currentBottomInset) {
scrollPosition.value = scrollPosition.value - currentBottomInset;
}
bottomInset.value = 0;
// Reset closing flag when keyboard fully closes
isKeyboardClosing.value = false;
isInteractiveGesture.value = false;
}
},
});
// ------------------------------------------------------------------
// SCROLL HANDLER
// ------------------------------------------------------------------
/**
* Handle scroll events on the UI thread for smooth performance
* Tracks scroll position to calculate proper keyboard offset
*/
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
const newScrollPosition = e.contentOffset.y + bottomInset.value;
// Preserve scrollPosition when keyboard is closed and list resets to 0
// This prevents iOS's list reset from overwriting the preserved scroll position
if (bottomInset.value > 0 || e.contentOffset.y > 0 || scrollPosition.value === 0) {
scrollPosition.value = newScrollPosition;
}
},
});
return {
keyboardTranslateY,
onScroll,
bottomInset,
scrollOffset,
keyboardHeight,
scrollPosition,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
isTransitioningFromCustomView,
};
};

View file

@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useEffect, useRef, useState} from 'react';
import {Platform} from 'react-native';
import {runOnUI} from 'react-native-reanimated';
import {useKeyboardAnimationContext} from '@context/keyboard_animation';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
/**
* Hook to handle focusing input after emoji picker is dismissed on Android.
* On Android, when the emoji picker is open and user tries to focus the input,
* the emoji picker closes but the keyboard doesn't open. This hook handles
* the delayed focus logic to ensure the keyboard opens properly.
*
*/
export const useFocusAfterEmojiDismiss = (
inputRef: React.MutableRefObject<PasteInputRef | undefined>,
focusInput: () => void,
) => {
const {
showInputAccessoryView,
setShowInputAccessoryView,
isInputAccessoryViewMode,
inputAccessoryViewAnimatedHeight,
keyboardTranslateY,
isTransitioningFromCustomView,
setIsEmojiSearchFocused,
} = useKeyboardAnimationContext();
const [isManuallyFocusingAfterEmojiDismiss, setIsManuallyFocusingAfterEmojiDismiss] = useState(false);
const isDismissingEmojiPicker = useRef(false);
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Handle focus after emoji picker is dismissed
useEffect(() => {
if (Platform.OS === 'android' && isManuallyFocusingAfterEmojiDismiss && !showInputAccessoryView) {
isDismissingEmojiPicker.current = false;
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
inputRef.current?.blur();
// Use requestAnimationFrame to ensure state updates have been applied
requestAnimationFrame(() => {
const handleDelayedFocus = () => {
focusInput();
setIsManuallyFocusingAfterEmojiDismiss(false);
focusTimeoutRef.current = null;
};
focusTimeoutRef.current = setTimeout(handleDelayedFocus, 200);
});
return () => {
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
focusTimeoutRef.current = null;
}
};
}
return undefined;
}, [isManuallyFocusingAfterEmojiDismiss, showInputAccessoryView, inputRef, focusInput]);
// Wrapped focus function that handles emoji picker dismissal
const focusWithEmojiDismiss = useCallback(() => {
if (Platform.OS === 'android' && showInputAccessoryView) {
isDismissingEmojiPicker.current = true;
setIsManuallyFocusingAfterEmojiDismiss(true);
runOnUI(() => {
'worklet';
inputAccessoryViewAnimatedHeight.value = 0;
keyboardTranslateY.value = 0;
isInputAccessoryViewMode.value = false;
isTransitioningFromCustomView.value = true;
})();
setIsEmojiSearchFocused(false);
setShowInputAccessoryView(false);
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
inputRef.current?.blur();
// Schedule focus after emoji picker closes
focusTimeoutRef.current = setTimeout(() => {
focusInput();
setIsManuallyFocusingAfterEmojiDismiss(false);
isDismissingEmojiPicker.current = false;
focusTimeoutRef.current = null;
}, 200);
} else {
focusInput();
}
}, [
showInputAccessoryView,
inputAccessoryViewAnimatedHeight,
setShowInputAccessoryView,
isInputAccessoryViewMode,
keyboardTranslateY,
isTransitioningFromCustomView,
setIsEmojiSearchFocused,
inputRef,
focusInput,
]);
return {
focus: focusWithEmojiDismiss,
isDismissingEmojiPicker,
focusTimeoutRef,
isManuallyFocusingAfterEmojiDismiss,
};
};

View file

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useState} from 'react';
import {runOnJS, useAnimatedReaction, useSharedValue, type SharedValue} from 'react-native-reanimated';
/**
* Default height for the input accessory view when keyboard height is unknown
*/
export const DEFAULT_INPUT_ACCESSORY_HEIGHT = 335;
type UseInputAccessoryViewParams = {
keyboardHeight: SharedValue<number>;
isKeyboardFullyOpen: SharedValue<boolean>;
};
export const useInputAccessoryView = ({
keyboardHeight,
isKeyboardFullyOpen,
}: UseInputAccessoryViewParams) => {
const [showInputAccessoryView, setShowInputAccessoryView] = useState(false);
const [lastKeyboardHeight, setLastKeyboardHeight] = useState(0);
const inputAccessoryViewAnimatedHeight = useSharedValue(0);
useAnimatedReaction(
() => ({
height: keyboardHeight.value,
isFullyOpen: isKeyboardFullyOpen.value,
}),
(current, previous) => {
if (
current.isFullyOpen &&
current.height > 0 &&
(!previous || !previous.isFullyOpen || previous.height !== current.height)
) {
runOnJS(setLastKeyboardHeight)(current.height);
}
},
[keyboardHeight, isKeyboardFullyOpen],
);
return {
showInputAccessoryView,
setShowInputAccessoryView,
lastKeyboardHeight,
setLastKeyboardHeight,
inputAccessoryViewAnimatedHeight,
};
};

View file

@ -0,0 +1,360 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act} from '@testing-library/react-hooks';
import {KeyboardController} from 'react-native-keyboard-controller';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useIsTablet} from '@hooks/device';
import {useKeyboardAnimation} from './keyboardAnimation';
import {useKeyboardAwarePostDraft} from './useKeyboardAwarePostDraft';
import {useKeyboardScrollAdjustment} from './useKeyboardScrollAdjustment';
jest.mock('react-native/Libraries/Utilities/Platform', () => ({
OS: 'ios',
select: jest.fn((obj) => obj.ios),
}));
jest.mock('react-native-keyboard-controller', () => ({
KeyboardController: {
dismiss: jest.fn(() => Promise.resolve()),
},
}));
jest.mock('react-native-reanimated', () => {
const sharedValues = new Map();
return {
...jest.requireActual('react-native-reanimated/mock'),
useSharedValue: jest.fn((initial) => {
const key = Math.random();
const sv = {
value: initial,
_key: key,
};
sharedValues.set(key, sv);
return sv;
}),
useDerivedValue: jest.fn((fn) => ({
value: fn(),
})),
useAnimatedStyle: jest.fn((fn) => fn()),
useAnimatedScrollHandler: jest.fn((handlers) => (event: {contentOffset: {y: number}}) => {
if (handlers.onScroll) {
handlers.onScroll(event);
}
}),
useAnimatedReaction: jest.fn(),
};
});
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: jest.fn(),
}));
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn(),
}));
jest.mock('./keyboardAnimation', () => ({
useKeyboardAnimation: jest.fn(),
}));
jest.mock('./useKeyboardScrollAdjustment', () => ({
useKeyboardScrollAdjustment: jest.fn(),
}));
describe('useKeyboardAwarePostDraft', () => {
const mockUseKeyboardAnimation = useKeyboardAnimation as jest.Mock;
const mockUseKeyboardScrollAdjustment = useKeyboardScrollAdjustment as jest.Mock;
const mockUseIsTablet = useIsTablet as jest.Mock;
const mockUseSafeAreaInsets = useSafeAreaInsets as jest.Mock;
const mockKeyboardControllerDismiss = KeyboardController.dismiss as jest.Mock;
const mockKeyboardAnimationReturn = {
keyboardTranslateY: {value: 0},
bottomInset: {value: 0},
scrollOffset: {value: 0},
scrollPosition: {value: 0},
onScroll: jest.fn(),
isKeyboardFullyOpen: {value: false},
isKeyboardFullyClosed: {value: true},
isKeyboardInTransition: {value: false},
isInputAccessoryViewMode: {value: false},
isTransitioningFromCustomView: {value: false},
};
beforeEach(() => {
jest.clearAllMocks();
mockUseIsTablet.mockReturnValue(false);
mockUseSafeAreaInsets.mockReturnValue({bottom: 0, top: 0, left: 0, right: 0});
mockUseKeyboardAnimation.mockReturnValue(mockKeyboardAnimationReturn);
mockUseKeyboardScrollAdjustment.mockImplementation(() => {});
});
describe('initialization', () => {
it('should initialize with default values', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(result.current.postInputContainerHeight).toBe(91);
expect(result.current.listRef.current).toBeNull();
expect(result.current.inputRef.current).toBeUndefined();
expect(result.current.keyboardTranslateY).toBe(mockKeyboardAnimationReturn.keyboardTranslateY);
expect(result.current.contentInset).toBe(mockKeyboardAnimationReturn.bottomInset);
expect(result.current.onScroll).toBe(mockKeyboardAnimationReturn.onScroll);
expect(result.current.blurInput).toBeDefined();
expect(result.current.focusInput).toBeDefined();
expect(result.current.blurAndDismissKeyboard).toBeDefined();
});
it('should call useKeyboardAnimation with correct parameters', () => {
renderHook(() => useKeyboardAwarePostDraft(false, true));
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
91, // DEFAULT_POST_INPUT_HEIGHT
true, // isIOS
false, // isTablet
0, // insets.bottom
false, // isThreadView
true, // enabled
);
});
it('should call useKeyboardScrollAdjustment with correct parameters', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(mockUseKeyboardScrollAdjustment).toHaveBeenCalledWith(
result.current.listRef,
mockKeyboardAnimationReturn.scrollPosition,
mockKeyboardAnimationReturn.scrollOffset,
true, // isIOS
mockKeyboardAnimationReturn.isInputAccessoryViewMode,
mockKeyboardAnimationReturn.isTransitioningFromCustomView,
);
});
});
describe('isThreadView parameter', () => {
it('should pass isThreadView=true to useKeyboardAnimation', () => {
renderHook(() => useKeyboardAwarePostDraft(true));
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
91,
true,
false,
0,
true, // isThreadView
true,
);
});
});
describe('enabled parameter', () => {
it('should pass enabled=false to useKeyboardAnimation', () => {
renderHook(() => useKeyboardAwarePostDraft(false, false));
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
91,
true,
false,
0,
false,
false, // enabled
);
});
});
describe('postInputContainerHeight', () => {
it('should update postInputContainerHeight when setPostInputContainerHeight is called', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
act(() => {
result.current.setPostInputContainerHeight(150);
});
expect(result.current.postInputContainerHeight).toBe(150);
});
it('should update postInputContainerHeight state', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
act(() => {
result.current.setPostInputContainerHeight(150);
});
expect(result.current.postInputContainerHeight).toBe(150);
});
});
describe('input ref callbacks', () => {
it('should call blur on inputRef when blurInput is called', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
const mockBlur = jest.fn();
// @ts-expect-error Test mock - only need blur method
result.current.inputRef.current = {blur: mockBlur};
act(() => {
result.current.blurInput();
});
expect(mockBlur).toHaveBeenCalled();
});
it('should not throw when blurInput is called without inputRef', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(() => {
act(() => {
result.current.blurInput();
});
}).not.toThrow();
});
it('should call focus on inputRef when focusInput is called', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
const mockFocus = jest.fn();
// @ts-expect-error Test mock - only need focus method
result.current.inputRef.current = {focus: mockFocus};
act(() => {
result.current.focusInput();
});
expect(mockFocus).toHaveBeenCalled();
});
it('should not throw when focusInput is called without inputRef', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(() => {
act(() => {
result.current.focusInput();
});
}).not.toThrow();
});
});
describe('blurAndDismissKeyboard', () => {
it('should reset shared values and dismiss keyboard', async () => {
const mockKeyboardTranslateY = {value: 300};
const mockBottomInset = {value: 300};
const mockScrollOffset = {value: 300};
const mockBlur = jest.fn();
mockUseKeyboardAnimation.mockReturnValue({
...mockKeyboardAnimationReturn,
keyboardTranslateY: mockKeyboardTranslateY,
bottomInset: mockBottomInset,
scrollOffset: mockScrollOffset,
});
const {result} = renderHook(() => useKeyboardAwarePostDraft());
// @ts-expect-error Test mock - only need blur method
result.current.inputRef.current = {blur: mockBlur};
await act(async () => {
await result.current.blurAndDismissKeyboard();
});
expect(mockKeyboardTranslateY.value).toBe(0);
expect(mockBottomInset.value).toBe(0);
expect(mockScrollOffset.value).toBe(0);
expect(mockBlur).toHaveBeenCalled();
expect(mockKeyboardControllerDismiss).toHaveBeenCalled();
});
it('should handle missing inputRef gracefully', async () => {
const mockKeyboardTranslateY = {value: 300};
const mockBottomInset = {value: 300};
const mockScrollOffset = {value: 300};
mockUseKeyboardAnimation.mockReturnValue({
...mockKeyboardAnimationReturn,
keyboardTranslateY: mockKeyboardTranslateY,
bottomInset: mockBottomInset,
scrollOffset: mockScrollOffset,
});
const {result} = renderHook(() => useKeyboardAwarePostDraft());
await act(async () => {
await result.current.blurAndDismissKeyboard();
});
expect(mockKeyboardTranslateY.value).toBe(0);
expect(mockBottomInset.value).toBe(0);
expect(mockScrollOffset.value).toBe(0);
expect(mockKeyboardControllerDismiss).toHaveBeenCalled();
});
});
describe('inputContainerAnimatedStyle', () => {
it('should return animated style with translateY on iOS', () => {
const mockKeyboardTranslateY = {value: 200};
mockUseKeyboardAnimation.mockReturnValue({
...mockKeyboardAnimationReturn,
keyboardTranslateY: mockKeyboardTranslateY,
});
const {result} = renderHook(() => useKeyboardAwarePostDraft());
const style = result.current.inputContainerAnimatedStyle;
expect(style).toEqual({
transform: [{translateY: -200}],
});
});
});
describe('tablet and safe area handling', () => {
it('should pass tablet and safe area values to useKeyboardAnimation', () => {
mockUseIsTablet.mockReturnValue(true);
mockUseSafeAreaInsets.mockReturnValue({bottom: 20, top: 0, left: 0, right: 0});
renderHook(() => useKeyboardAwarePostDraft());
expect(mockUseKeyboardAnimation).toHaveBeenCalledWith(
91,
true,
true, // isTablet
20, // insets.bottom
false,
true,
);
});
});
describe('return values', () => {
it('should return all expected properties', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(result.current).toHaveProperty('keyboardTranslateY');
expect(result.current).toHaveProperty('listRef');
expect(result.current).toHaveProperty('inputRef');
expect(result.current).toHaveProperty('contentInset');
expect(result.current).toHaveProperty('onScroll');
expect(result.current).toHaveProperty('postInputContainerHeight');
expect(result.current).toHaveProperty('setPostInputContainerHeight');
expect(result.current).toHaveProperty('inputContainerAnimatedStyle');
expect(result.current).toHaveProperty('keyboardHeight');
expect(result.current).toHaveProperty('scrollOffset');
expect(result.current).toHaveProperty('scrollPosition');
expect(result.current).toHaveProperty('blurInput');
expect(result.current).toHaveProperty('focusInput');
expect(result.current).toHaveProperty('blurAndDismissKeyboard');
expect(result.current).toHaveProperty('isKeyboardFullyOpen');
expect(result.current).toHaveProperty('isKeyboardFullyClosed');
expect(result.current).toHaveProperty('isKeyboardInTransition');
});
it('should return keyboardHeight as alias for keyboardTranslateY', () => {
const {result} = renderHook(() => useKeyboardAwarePostDraft());
expect(result.current.keyboardHeight).toBe(result.current.keyboardTranslateY);
});
});
});

View file

@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useRef, useState} from 'react';
import {FlatList, Platform} from 'react-native';
import {KeyboardController} from 'react-native-keyboard-controller';
import {useAnimatedStyle} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {useIsTablet} from '@hooks/device';
import {useKeyboardAnimation} from './keyboardAnimation';
import {useKeyboardScrollAdjustment} from './useKeyboardScrollAdjustment';
import type {PasteInputRef} from '@mattermost/react-native-paste-input';
import type PostModel from '@typings/database/models/servers/post';
/**
* Default height for post input container
* Empirically measured value for iOS to prevent content shifting on initial render
*/
const DEFAULT_POST_INPUT_HEIGHT = 91;
const isIOS = Platform.OS === 'ios';
export const useKeyboardAwarePostDraft = (isThreadView = false, enabled = true) => {
const [postInputContainerHeight, setPostInputContainerHeight] = useState(DEFAULT_POST_INPUT_HEIGHT);
const listRef = useRef<FlatList<string | PostModel>>(null);
const inputRef = useRef<PasteInputRef>();
const isTablet = useIsTablet();
const insets = useSafeAreaInsets();
const {
keyboardTranslateY,
bottomInset,
scrollOffset,
scrollPosition,
onScroll,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
isTransitioningFromCustomView,
} = useKeyboardAnimation(postInputContainerHeight, isIOS, isTablet, insets.bottom, isThreadView, enabled);
// Only apply scroll adjustment on iOS, Android uses native keyboard handling
// Also pass isInputAccessoryViewMode and isTransitioningFromCustomView to control scroll behavior
useKeyboardScrollAdjustment(listRef, scrollPosition, scrollOffset, isIOS, isInputAccessoryViewMode, isTransitioningFromCustomView);
const inputContainerAnimatedStyle = useAnimatedStyle(
() => {
return {
transform: [{translateY: isIOS ? -keyboardTranslateY.value : 0}],
};
},
[],
);
const blurInput = useCallback(() => {
inputRef.current?.blur();
}, []);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
const blurAndDismissKeyboard = useCallback(async () => {
bottomInset.value = 0;
scrollOffset.value = 0;
keyboardTranslateY.value = 0;
inputRef.current?.blur();
await KeyboardController.dismiss();
}, [keyboardTranslateY, bottomInset, scrollOffset]);
return {
keyboardTranslateY,
listRef,
inputRef,
contentInset: bottomInset,
onScroll,
postInputContainerHeight,
setPostInputContainerHeight,
inputContainerAnimatedStyle,
keyboardHeight: keyboardTranslateY,
scrollOffset,
scrollPosition,
blurInput,
focusInput,
blurAndDismissKeyboard,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
isTransitioningFromCustomView,
};
};

View file

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback} from 'react';
import {runOnJS, useAnimatedReaction, type SharedValue} from 'react-native-reanimated';
import type PostModel from '@typings/database/models/servers/post';
import type {FlatList} from 'react-native';
/**
* Custom hook to handle automatic scrolling when keyboard opens
* Keeps messages in the same relative position to the input container
* Only enabled on iOS - Android uses native keyboard handling
*/
export const useKeyboardScrollAdjustment = (
scrollViewRef: React.RefObject<FlatList<string | PostModel>>,
scrollPosition: SharedValue<number>,
scrollOffset: SharedValue<number>,
enabled = true,
isInputAccessoryViewMode?: SharedValue<boolean>,
isTransitioningFromCustomView?: SharedValue<boolean>,
) => {
// Callback to scroll the view (needs to be stable reference for runOnJS)
const scrollToOffset = useCallback(
(offsetValue: number, scrollValue: number) => {
scrollViewRef.current?.scrollToOffset({
offset: -offsetValue + scrollValue,
animated: false,
});
},
[scrollViewRef],
);
// Watch for scrollOffset changes and adjust scroll position accordingly
useAnimatedReaction(
() => ({
scrollOffset: scrollOffset.value,
scrollPosition: scrollPosition.value,
isInputAccessoryViewMode: isInputAccessoryViewMode?.value || false,
isTransitioning: isTransitioningFromCustomView?.value || false,
}),
(current, previous) => {
'worklet';
// Skip scroll adjustment when:
// - Input Accessory view (emoji picker) is active
// - Transitioning from custom view to keyboard (heights are same, no scroll needed)
// - scrollOffset hasn't actually changed (prevents re-scrolling after transition ends)
const scrollOffsetChanged = previous === null || Math.abs(current.scrollOffset - (previous?.scrollOffset || 0)) > 0.5;
// Don't scroll when keyboard closes - let the list naturally return to position
if (current.scrollOffset === 0) {
return;
}
// Perform scroll adjustment when keyboard is opening or height changes
// scrollPosition is preserved (not reset to 0) by keyboardAnimation.ts
if (enabled && !current.isInputAccessoryViewMode && !current.isTransitioning && scrollOffsetChanged) {
runOnJS(scrollToOffset)(current.scrollOffset, current.scrollPosition);
}
},
[scrollPosition, scrollOffset, enabled, isInputAccessoryViewMode, isTransitioningFromCustomView],
);
};

View file

@ -0,0 +1,248 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook, act} from '@testing-library/react-hooks';
import {BehaviorSubject} from 'rxjs';
import {Screens} from '@constants';
import NavigationStore from '@store/navigation_store';
import {useIsScreenVisible} from './use_screen_visibility';
const mockSubject = new BehaviorSubject<string | undefined>(undefined);
jest.mock('@store/navigation_store', () => ({
getVisibleScreen: jest.fn(() => mockSubject.value),
getSubject: jest.fn(() => mockSubject),
}));
describe('useIsScreenVisible', () => {
const mockNavigationStore = NavigationStore as jest.Mocked<typeof NavigationStore>;
beforeEach(() => {
jest.clearAllMocks();
mockSubject.next(undefined);
});
describe('initialization', () => {
it('should return false when componentId is undefined', () => {
const {result} = renderHook(() => useIsScreenVisible(undefined));
expect(result.current).toBe(false);
});
it('should return true when componentId matches visible screen on mount', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.CHANNEL));
expect(result.current).toBe(true);
});
it('should return false when componentId does not match visible screen on mount', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
expect(result.current).toBe(false);
});
});
describe('subscription updates', () => {
it('should update when visible screen changes to match componentId', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
expect(result.current).toBe(false);
act(() => {
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
});
it('should update when visible screen changes away from componentId', () => {
mockSubject.next(Screens.THREAD);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.THREAD);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
expect(result.current).toBe(true);
act(() => {
mockSubject.next(Screens.CHANNEL);
});
expect(result.current).toBe(false);
});
it('should handle multiple screen changes', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
act(() => {
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
act(() => {
mockSubject.next(Screens.CHANNEL);
});
expect(result.current).toBe(false);
act(() => {
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
});
it('should handle undefined visible screen', () => {
mockSubject.next(Screens.THREAD);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.THREAD);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
expect(result.current).toBe(true);
act(() => {
mockSubject.next(undefined);
});
expect(result.current).toBe(false);
});
});
describe('componentId changes', () => {
it('should update when componentId changes', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result, rerender} = renderHook(
({componentId}: {componentId?: typeof Screens.CHANNEL | typeof Screens.THREAD}) => useIsScreenVisible(componentId),
{initialProps: {componentId: Screens.CHANNEL}},
);
expect(result.current).toBe(true);
rerender({componentId: Screens.THREAD});
expect(result.current).toBe(false);
});
it('should set to false when componentId changes to undefined', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result, rerender} = renderHook(
({componentId}: {componentId?: typeof Screens.CHANNEL}) => useIsScreenVisible(componentId),
{initialProps: {componentId: Screens.CHANNEL}},
);
expect(result.current).toBe(true);
rerender({componentId: undefined});
expect(result.current).toBe(false);
});
it('should subscribe to new componentId when it changes', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result, rerender} = renderHook(
({componentId}: {componentId?: typeof Screens.CHANNEL | typeof Screens.THREAD}) => useIsScreenVisible(componentId),
{initialProps: {componentId: Screens.CHANNEL}},
);
rerender({componentId: Screens.THREAD});
act(() => {
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
});
});
describe('cleanup', () => {
it('should unsubscribe when component unmounts', () => {
const unsubscribe = jest.fn();
const subscribeSpy = jest.spyOn(mockSubject, 'subscribe').mockReturnValue({
unsubscribe,
} as unknown as ReturnType<typeof mockSubject.subscribe>);
const {unmount} = renderHook(() => useIsScreenVisible(Screens.CHANNEL));
unmount();
expect(unsubscribe).toHaveBeenCalled();
subscribeSpy.mockRestore();
});
it('should unsubscribe when componentId changes', () => {
const unsubscribe1 = jest.fn();
const unsubscribe2 = jest.fn();
let callCount = 0;
const subscribeSpy = jest.spyOn(mockSubject, 'subscribe').mockImplementation(() => {
callCount++;
if (callCount === 1) {
return {unsubscribe: unsubscribe1} as unknown as ReturnType<typeof mockSubject.subscribe>;
}
return {unsubscribe: unsubscribe2} as unknown as ReturnType<typeof mockSubject.subscribe>;
});
const {rerender} = renderHook(
({componentId}: {componentId?: typeof Screens.CHANNEL | typeof Screens.THREAD}) => useIsScreenVisible(componentId),
{initialProps: {componentId: Screens.CHANNEL}},
);
rerender({componentId: Screens.THREAD as typeof Screens.CHANNEL | typeof Screens.THREAD});
expect(unsubscribe1).toHaveBeenCalled();
subscribeSpy.mockRestore();
});
});
describe('edge cases', () => {
it('should handle rapid screen changes', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.THREAD));
act(() => {
mockSubject.next(Screens.THREAD);
mockSubject.next(Screens.CHANNEL);
mockSubject.next(Screens.THREAD);
});
expect(result.current).toBe(true);
});
it('should handle same screen being set multiple times', () => {
mockSubject.next(Screens.CHANNEL);
mockNavigationStore.getVisibleScreen.mockReturnValue(Screens.CHANNEL);
const {result} = renderHook(() => useIsScreenVisible(Screens.CHANNEL));
expect(result.current).toBe(true);
act(() => {
mockSubject.next(Screens.CHANNEL);
mockSubject.next(Screens.CHANNEL);
});
expect(result.current).toBe(true);
});
});
});

View file

@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import NavigationStore from '@store/navigation_store';
import type {AvailableScreens} from '@typings/screens/navigation';
export const useIsScreenVisible = (componentId?: AvailableScreens): boolean => {
const [isVisible, setIsVisible] = useState(() => {
if (!componentId) {
return false;
}
return NavigationStore.getVisibleScreen() === componentId;
});
useEffect(() => {
if (!componentId) {
setIsVisible(false);
return undefined;
}
setIsVisible(NavigationStore.getVisibleScreen() === componentId);
const subscription = NavigationStore.getSubject().subscribe((visibleScreen) => {
setIsVisible(visibleScreen === componentId);
});
return () => {
subscription.unsubscribe();
};
}, [componentId]);
return isVisible;
};

View file

@ -0,0 +1,430 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, waitFor} from '@testing-library/react-native';
import {Alert} from 'react-native';
import {Screens} from '@constants';
import {dismissBottomSheet} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import PickerUtil from '@utils/file/file_picker';
import AttachmentOptions from './attachment_options';
jest.mock('@screens/navigation', () => ({
dismissBottomSheet: jest.fn(() => Promise.resolve()),
}));
jest.mock('@utils/file/file_picker');
jest.mock('@utils/file', () => ({
fileMaxWarning: jest.fn((...args) => `Maximum ${args[1]} files allowed`),
uploadDisabledWarning: jest.fn(() => 'File uploads are disabled'),
}));
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn(() => false),
}));
describe('AttachmentOptions', () => {
const mockDismissBottomSheet = dismissBottomSheet as jest.Mock;
const mockAttachFileFromPhotoGallery = jest.fn();
const mockAttachFileFromCamera = jest.fn();
const mockAttachFileFromFiles = jest.fn();
const baseProps = {
componentId: 'Channel' as const,
onUploadFiles: jest.fn(),
maxFileCount: 10,
fileCount: 0,
maxFilesReached: false,
canUploadFiles: true,
testID: 'test-attachment-options',
};
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Alert, 'alert');
(PickerUtil as jest.Mock).mockImplementation(() => ({
attachFileFromPhotoGallery: mockAttachFileFromPhotoGallery,
attachFileFromCamera: mockAttachFileFromCamera,
attachFileFromFiles: mockAttachFileFromFiles,
}));
});
describe('action handlers', () => {
it('should call picker.attachFileFromPhotoGallery when photo library is selected', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(10);
});
});
it('should call picker.attachFileFromCamera when take photo is selected', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
const takePhotoItem = getByTestId('file_attachment.take_photo');
fireEvent.press(takePhotoItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(mockAttachFileFromCamera).toHaveBeenCalledWith({
quality: 0.8,
mediaType: 'photo',
saveToPhotos: true,
});
});
});
it('should call picker.attachFileFromCamera when take video is selected', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
const takeVideoItem = getByTestId('file_attachment.take_video');
fireEvent.press(takeVideoItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(mockAttachFileFromCamera).toHaveBeenCalledWith({
quality: 0.8,
videoQuality: 'high',
mediaType: 'video',
saveToPhotos: true,
});
});
});
it('should call picker.attachFileFromFiles when attach file is selected', async () => {
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
const attachFileItem = getByTestId('file_attachment.attach_file');
fireEvent.press(attachFileItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(mockAttachFileFromFiles).toHaveBeenCalledWith(undefined, true);
});
});
});
describe('validation - canUploadFiles', () => {
it('should show alert and not call picker when canUploadFiles is false for photo library', async () => {
const props = {
...baseProps,
canUploadFiles: false,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'File uploads are disabled',
);
expect(mockAttachFileFromPhotoGallery).not.toHaveBeenCalled();
});
});
it('should show alert and not call picker when canUploadFiles is false for camera', async () => {
const props = {
...baseProps,
canUploadFiles: false,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const takePhotoItem = getByTestId('file_attachment.take_photo');
fireEvent.press(takePhotoItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'File uploads are disabled',
);
expect(mockAttachFileFromCamera).not.toHaveBeenCalled();
});
});
it('should show alert and not call picker when canUploadFiles is false for attach file', async () => {
const props = {
...baseProps,
canUploadFiles: false,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const attachFileItem = getByTestId('file_attachment.attach_file');
fireEvent.press(attachFileItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'File uploads are disabled',
);
expect(mockAttachFileFromFiles).not.toHaveBeenCalled();
});
});
});
describe('validation - maxFilesReached', () => {
it('should show alert and not call picker when maxFilesReached is true for photo library', async () => {
const props = {
...baseProps,
maxFilesReached: true,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'Maximum 10 files allowed',
);
expect(mockAttachFileFromPhotoGallery).not.toHaveBeenCalled();
});
});
it('should show alert and not call picker when maxFilesReached is true for camera', async () => {
const props = {
...baseProps,
maxFilesReached: true,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const takePhotoItem = getByTestId('file_attachment.take_photo');
fireEvent.press(takePhotoItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'Maximum 10 files allowed',
);
expect(mockAttachFileFromCamera).not.toHaveBeenCalled();
});
});
it('should show alert and not call picker when maxFilesReached is true for attach file', async () => {
const props = {
...baseProps,
maxFilesReached: true,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const attachFileItem = getByTestId('file_attachment.attach_file');
fireEvent.press(attachFileItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'Maximum 10 files allowed',
);
expect(mockAttachFileFromFiles).not.toHaveBeenCalled();
});
});
it('should not show alert when maxFilesReached is true but maxFileCount is undefined', async () => {
const props = {
...baseProps,
maxFilesReached: true,
maxFileCount: undefined,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockDismissBottomSheet).toHaveBeenCalledWith(Screens.ATTACHMENT_OPTIONS);
expect(Alert.alert).not.toHaveBeenCalled();
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalled();
});
});
});
describe('selection limit calculation', () => {
it('should pass correct selection limit when fileCount is less than maxFileCount', async () => {
const props = {
...baseProps,
fileCount: 3,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(7);
});
});
it('should pass undefined selection limit when maxFileCount is not provided', async () => {
const props = {
...baseProps,
maxFileCount: undefined,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(undefined);
});
});
it('should pass undefined selection limit when fileCount equals maxFileCount', async () => {
const props = {
...baseProps,
fileCount: 10,
maxFileCount: 10,
};
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(0);
});
});
});
describe('edge cases', () => {
it('should use default closeButtonId when not provided', () => {
const props = {
...baseProps,
};
delete (props as {closeButtonId?: string}).closeButtonId;
renderWithIntlAndTheme(<AttachmentOptions {...props}/>);
// Component should render without errors
});
it('should use default fileCount when not provided', async () => {
const props = {
...baseProps,
};
delete (props as {fileCount?: number}).fileCount;
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalledWith(10);
});
});
it('should use default maxFilesReached when not provided', async () => {
const props = {
...baseProps,
};
delete (props as {maxFilesReached?: boolean}).maxFilesReached;
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalled();
expect(Alert.alert).not.toHaveBeenCalled();
});
});
it('should use default canUploadFiles when not provided', async () => {
const props = {
...baseProps,
};
delete (props as {canUploadFiles?: boolean}).canUploadFiles;
const {getByTestId} = renderWithIntlAndTheme(
<AttachmentOptions {...props}/>,
);
const photoLibraryItem = getByTestId('file_attachment.photo_library');
fireEvent.press(photoLibraryItem);
await waitFor(() => {
expect(mockAttachFileFromPhotoGallery).toHaveBeenCalled();
expect(Alert.alert).not.toHaveBeenCalled();
});
});
});
describe('tablet rendering', () => {
it('should not render title when isTablet is true', () => {
const useIsTablet = require('@hooks/device').useIsTablet;
useIsTablet.mockReturnValue(true);
const {queryByText} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
expect(queryByText('Files and media')).toBeNull();
});
it('should render title when isTablet is false', () => {
const useIsTablet = require('@hooks/device').useIsTablet;
useIsTablet.mockReturnValue(false);
const {getByText} = renderWithIntlAndTheme(
<AttachmentOptions {...baseProps}/>,
);
expect(getByText('Files and media')).toBeTruthy();
});
});
});

View file

@ -0,0 +1,188 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Alert, View} from 'react-native';
import {type CameraOptions} from 'react-native-image-picker';
import FormattedText from '@components/formatted_text';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import BottomSheet from '@screens/bottom_sheet';
import {dismissBottomSheet} from '@screens/navigation';
import {fileMaxWarning, uploadDisabledWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {AvailableScreens} from '@typings/screens/navigation';
const TITLE_HEIGHT = 54;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
title: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
marginBottom: 8,
},
}));
type Props = {
componentId: AvailableScreens;
closeButtonId?: string;
onUploadFiles: (files: ExtractedFileInfo[]) => void;
maxFileCount?: number;
fileCount?: number;
maxFilesReached?: boolean;
canUploadFiles?: boolean;
testID?: string;
}
const AttachmentOptions: React.FC<Props> = ({
componentId,
closeButtonId = 'attachment-close-id',
onUploadFiles,
maxFileCount,
fileCount = 0,
maxFilesReached = false,
canUploadFiles = true,
testID,
}) => {
const theme = useTheme();
const isTablet = useIsTablet();
const intl = useIntl();
const styles = getStyleSheet(theme);
const picker = useMemo(() => new PickerUtil(intl, onUploadFiles), [intl, onUploadFiles]);
const checkCanUpload = () => {
if (!canUploadFiles) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
uploadDisabledWarning(intl),
);
return false;
}
return true;
};
const checkMaxFiles = () => {
if (maxFilesReached && maxFileCount) {
Alert.alert(
intl.formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
fileMaxWarning(intl, maxFileCount),
);
return true;
}
return false;
};
const onChooseFromPhotoLibrary = async () => {
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
if (!checkCanUpload() || checkMaxFiles()) {
return;
}
const selectionLimit = maxFileCount ? maxFileCount - fileCount : undefined;
picker.attachFileFromPhotoGallery(selectionLimit);
};
const onTakePhoto = async () => {
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
if (!checkCanUpload() || checkMaxFiles()) {
return;
}
const options: CameraOptions = {
quality: 0.8,
mediaType: 'photo',
saveToPhotos: true,
};
picker.attachFileFromCamera(options);
};
const onTakeVideo = async () => {
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
if (!checkCanUpload() || checkMaxFiles()) {
return;
}
const options: CameraOptions = {
quality: 0.8,
videoQuality: 'high',
mediaType: 'video',
saveToPhotos: true,
};
picker.attachFileFromCamera(options);
};
const onAttachFile = async () => {
await dismissBottomSheet(Screens.ATTACHMENT_OPTIONS);
if (!checkCanUpload() || checkMaxFiles()) {
return;
}
picker.attachFileFromFiles(undefined, true);
};
const renderContent = () => {
return (
<View>
{!isTablet &&
<FormattedText
id='mobile.file_attachment.title'
defaultMessage='Files and media'
style={styles.title}
/>
}
<SlideUpPanelItem
leftIcon='image-outline'
onPress={onChooseFromPhotoLibrary}
testID='file_attachment.photo_library'
text={intl.formatMessage({id: 'mobile.file_upload.library', defaultMessage: 'Choose from photo library'})}
/>
<SlideUpPanelItem
leftIcon='camera-outline'
onPress={onTakePhoto}
testID='file_attachment.take_photo'
text={intl.formatMessage({id: 'mobile.file_upload.camera_photo', defaultMessage: 'Take a photo'})}
/>
<SlideUpPanelItem
leftIcon='video-outline'
onPress={onTakeVideo}
testID='file_attachment.take_video'
text={intl.formatMessage({id: 'mobile.file_upload.camera_video', defaultMessage: 'Take a video'})}
/>
<SlideUpPanelItem
leftIcon='paperclip'
onPress={onAttachFile}
testID='file_attachment.attach_file'
text={intl.formatMessage({id: 'mobile.file_upload.browse', defaultMessage: 'Attach a file'})}
/>
</View>
);
};
const snapPoints = useMemo(() => {
const componentHeight = TITLE_HEIGHT + bottomSheetSnapPoint(4, ITEM_HEIGHT);
return [1, componentHeight];
}, []);
return (
<BottomSheet
componentId={componentId}
renderContent={renderContent}
closeButtonId={closeButtonId}
snapPoints={snapPoints}
testID={testID}
/>
);
};
export default AttachmentOptions;

View file

@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './attachment_options';

View file

@ -1,27 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {type LayoutChangeEvent, StyleSheet, View} from 'react-native';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, type LayoutChangeEvent, StyleSheet} from 'react-native';
import {KeyboardProvider} from 'react-native-keyboard-controller';
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {storeLastViewedChannelIdAndServer, removeLastViewedChannelIdAndServer} from '@actions/app/global';
import FloatingCallContainer from '@calls/components/floating_call_container';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
import {Screens} from '@constants';
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useChannelSwitch} from '@hooks/channel_switch';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {useTeamSwitch} from '@hooks/team_switch';
import {useIsScreenVisible} from '@hooks/use_screen_visibility';
import SecurityManager from '@managers/security_manager';
import {popTopScreen} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import ChannelPostList from './channel_post_list';
import ChannelContent from './channel_content';
import ChannelHeader from './header';
import useGMasDMNotice from './use_gm_as_dm_notice';
@ -46,8 +44,6 @@ type ChannelProps = {
scheduledPostCount: number;
};
const edges: Edge[] = ['left', 'right'];
const styles = StyleSheet.create({
flex: {
flex: 1,
@ -80,6 +76,19 @@ const Channel = ({
const defaultHeight = useDefaultHeaderHeight();
const [containerHeight, setContainerHeight] = useState(0);
const shouldRender = !switchingTeam && !switchingChannels && shouldRenderPosts && Boolean(channelId);
const isVisible = useIsScreenVisible(componentId);
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
const safeAreaViewEdges: Edge[] = useMemo(() => {
if (isTablet) {
return ['left', 'right'];
}
if (isEmojiSearchFocused) {
return ['left', 'right'];
}
return ['left', 'right', 'bottom'];
}, [isTablet, isEmojiSearchFocused]);
const handleBack = useCallback(() => {
popTopScreen(componentId);
}, [componentId]);
@ -120,7 +129,7 @@ const Channel = ({
<SafeAreaView
style={styles.flex}
mode='margin'
edges={edges}
edges={safeAreaViewEdges}
testID='channel.screen'
onLayout={onLayout}
nativeID={componentId ? SecurityManager.getShieldScreenId(componentId) : undefined}
@ -134,29 +143,31 @@ const Channel = ({
shouldRenderBookmarks={shouldRender}
shouldRenderChannelBanner={includeChannelBanner}
/>
{shouldRender &&
<ExtraKeyboardProvider>
<View style={[styles.flex, {marginTop}]}>
<ChannelPostList
{Platform.OS === 'ios' ? (
<KeyboardProvider>
{shouldRender && (
<ChannelContent
channelId={channelId}
marginTop={marginTop}
scheduledPostCount={scheduledPostCount}
containerHeight={containerHeight}
enabled={isVisible || shouldRender}
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
/>
)}
</KeyboardProvider>
) : (
shouldRender && (
<ChannelContent
channelId={channelId}
nativeID={channelId}
marginTop={marginTop}
scheduledPostCount={scheduledPostCount}
containerHeight={containerHeight}
enabled={isVisible || shouldRender}
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
/>
</View>
<>
{scheduledPostCount > 0 &&
<ScheduledPostIndicator scheduledPostCount={scheduledPostCount}/>
}
</>
<PostDraft
channelId={channelId}
testID='channel.post_draft'
containerHeight={containerHeight}
isChannelScreen={true}
canShowPostPriority={true}
location={Screens.CHANNEL}
/>
</ExtraKeyboardProvider>
}
)
)}
{showFloatingCallContainer && shouldRender &&
<FloatingCallContainer
channelId={channelId}

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PortalProvider} from '@gorhom/portal';
import React from 'react';
import {StyleSheet} from 'react-native';
import {KeyboardAwarePostDraftContainer} from '@components/keyboard_aware_post_draft_container';
import PostDraft from '@components/post_draft';
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
import {Screens} from '@constants';
import ChannelPostList from './channel_post_list';
type ChannelContentProps = {
channelId: string;
marginTop: number;
scheduledPostCount: number;
containerHeight: number;
enabled?: boolean;
onEmojiSearchFocusChange?: (focused: boolean) => void;
}
const CHANNEL_POST_DRAFT_TESTID = 'channel.post_draft';
// This follows the same pattern as draft_input.tsx: `${testID}.post.input`
const CHANNEL_POST_INPUT_NATIVE_ID = `${CHANNEL_POST_DRAFT_TESTID}.post.input`;
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
const ChannelContent = ({
channelId,
marginTop,
scheduledPostCount,
containerHeight,
enabled = true,
onEmojiSearchFocusChange,
}: ChannelContentProps) => {
return (
<PortalProvider>
<KeyboardAwarePostDraftContainer
textInputNativeID={CHANNEL_POST_INPUT_NATIVE_ID}
containerStyle={[styles.flex, {marginTop}]}
enabled={enabled}
onEmojiSearchFocusChange={onEmojiSearchFocusChange}
renderList={({listRef, onTouchMove, onTouchEnd}) => (
<ChannelPostList
channelId={channelId}
listRef={listRef}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
)}
>
{scheduledPostCount > 0 &&
<ScheduledPostIndicator scheduledPostCount={scheduledPostCount}/>
}
<PostDraft
channelId={channelId}
testID={CHANNEL_POST_DRAFT_TESTID}
containerHeight={containerHeight}
isChannelScreen={true}
canShowPostPriority={true}
location={Screens.CHANNEL}
/>
</KeyboardAwarePostDraftContainer>
</PortalProvider>
);
};
export default ChannelContent;

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {type StyleProp, StyleSheet, type ViewStyle, DeviceEventEmitter} from 'react-native';
import {type StyleProp, StyleSheet, type ViewStyle, DeviceEventEmitter, type FlatList, type GestureResponderEvent} from 'react-native';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {markChannelAsRead, unsetActiveChannelOnServer} from '@actions/remote/channel';
@ -26,9 +26,11 @@ type Props = {
contentContainerStyle?: StyleProp<AnimatedStyle<ViewStyle>>;
isCRTEnabled: boolean;
lastViewedAt: number;
nativeID: string;
posts: PostModel[];
shouldShowJoinLeaveMessages: boolean;
listRef: React.RefObject<FlatList<string | PostModel>>;
onTouchMove?: (event: GestureResponderEvent) => void;
onTouchEnd?: () => void;
}
const edges: Edge[] = [];
@ -39,7 +41,8 @@ const styles = StyleSheet.create({
const ChannelPostList = ({
channelId, contentContainerStyle, isCRTEnabled,
lastViewedAt, nativeID, posts, shouldShowJoinLeaveMessages,
lastViewedAt, posts, shouldShowJoinLeaveMessages,
listRef, onTouchMove, onTouchEnd,
}: Props) => {
const appState = useAppState();
const isTablet = useIsTablet();
@ -122,12 +125,14 @@ const ChannelPostList = ({
footer={intro}
lastViewedAt={lastViewedAt}
location={Screens.CHANNEL}
nativeID={nativeID}
onEndReached={onEndReached}
posts={posts}
shouldShowJoinLeaveMessages={shouldShowJoinLeaveMessages}
showMoreMessages={true}
testID='channel.post_list'
listRef={listRef}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
);

View file

@ -1,10 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, waitFor} from '@testing-library/react-native';
import React from 'react';
import {Alert} from 'react-native';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import * as Navigation from '@screens/navigation';
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import PickerUtil from '@utils/file/file_picker';
@ -13,6 +15,7 @@ import EditPost from './edit_post';
import type {Database} from '@nozbe/watermelondb';
import type PostModel from '@typings/database/models/servers/post';
import type {IntlShape} from 'react-intl';
jest.mock('@utils/file/file_picker');
jest.mock('@managers/draft_upload_manager', () => ({
@ -22,6 +25,13 @@ jest.mock('@managers/draft_upload_manager', () => ({
registerErrorHandler: jest.fn(() => jest.fn()),
cancel: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
openAttachmentOptions: jest.fn(),
buildNavigationButton: jest.fn((id: string, testID: string) => ({id, testID})),
dismissBottomSheet: jest.fn(() => Promise.resolve()),
dismissModal: jest.fn(),
setButtons: jest.fn(),
}));
const TEST_CONFIG = {
serverUrl: 'baseHandler.test.com',
@ -110,6 +120,14 @@ describe('Edit Post', () => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
attachFileFromPhotoGallery: jest.fn(() => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
attachFileFromCamera: jest.fn(() => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
}) as unknown as PickerUtil);
};
@ -120,12 +138,30 @@ describe('Edit Post', () => {
});
};
const triggerFileUpload = (screen: ReturnType<typeof renderEditPost>) => {
fireEvent.press(screen.getByTestId('edit_post.quick_actions.file_action'));
const triggerFileUpload = async (screen: ReturnType<typeof renderEditPost>) => {
let onUploadFilesCallback: ((files: ExtractedFileInfo[]) => void) | undefined;
jest.mocked(Navigation.openAttachmentOptions).mockImplementation((intl, theme, props) => {
onUploadFilesCallback = props?.onUploadFiles;
return undefined;
});
await act(async () => {
fireEvent.press(screen.getByTestId('edit_post.quick_actions.attachment_action'));
});
if (onUploadFilesCallback) {
await act(async () => {
const mockIntl = {formatMessage: jest.fn()} as unknown as IntlShape;
const mockPicker = new PickerUtil(mockIntl, onUploadFilesCallback as (files: ExtractedFileInfo[]) => void);
await mockPicker.attachFileFromFiles(undefined, true);
});
}
};
const triggerFileRemoval = (screen: ReturnType<typeof renderEditPost>, fileId: string) => {
fireEvent.press(screen.getByTestId(`remove-button-${fileId}`));
const triggerFileRemoval = async (screen: ReturnType<typeof renderEditPost>, fileId: string) => {
await act(async () => {
fireEvent.press(screen.getByTestId(`remove-button-${fileId}`));
});
};
beforeAll(async () => {
@ -159,53 +195,61 @@ describe('Edit Post', () => {
});
describe('File Upload Validation', () => {
it('should show error when file uploads are disabled', () => {
it('should show error when file uploads are disabled', async () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, canUploadFiles: false};
const screen = renderEditPost(props);
triggerFileUpload(screen);
await triggerFileUpload(screen);
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
});
});
it('should show error when maximum file count is reached', () => {
it('should show error when maximum file count is reached', async () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, maxFileCount: 1};
const screen = renderEditPost(props);
triggerFileUpload(screen);
await triggerFileUpload(screen);
expect(screen.getByText(ERROR_MESSAGES.maxFilesReached)).toBeVisible();
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.maxFilesReached)).toBeVisible();
});
});
it('should show error when file size exceeds limit', () => {
it('should show error when file size exceeds limit', async () => {
setupPickerMock(TEST_FILES.largeFile);
const props = {...baseProps, maxFileSize: 1000};
const screen = renderEditPost(props);
triggerFileUpload(screen);
await triggerFileUpload(screen);
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
});
});
});
describe('File Upload Integration', () => {
it('should integrate with DraftEditPostUploadManager for successful uploads', () => {
it('should integrate with DraftEditPostUploadManager for successful uploads', async () => {
setupPickerMock(TEST_FILES.newFile);
const screen = renderEditPost();
triggerFileUpload(screen);
await triggerFileUpload(screen);
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(
TEST_CONFIG.serverUrl,
TEST_FILES.newFile,
baseProps.post.channelId,
baseProps.post.rootId,
0,
true,
expect.any(Function),
);
expect(DraftEditPostUploadManager.registerErrorHandler).toHaveBeenCalledWith(
TEST_FILES.newFile.clientId,
expect.any(Function),
);
await waitFor(() => {
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(
TEST_CONFIG.serverUrl,
TEST_FILES.newFile,
baseProps.post.channelId,
baseProps.post.rootId,
0,
true,
expect.any(Function),
);
expect(DraftEditPostUploadManager.registerErrorHandler).toHaveBeenCalledWith(
TEST_FILES.newFile.clientId,
expect.any(Function),
);
});
});
});
@ -215,49 +259,57 @@ describe('Edit Post', () => {
setupPickerMock(TEST_FILES.newFile);
});
it('should remove newly uploaded files without confirmation', () => {
it('should remove newly uploaded files without confirmation', async () => {
const screen = renderEditPost();
triggerFileUpload(screen);
triggerFileRemoval(screen, TEST_FILES.newFile.id);
await triggerFileUpload(screen);
await triggerFileRemoval(screen, TEST_FILES.newFile.id);
expect(Alert.alert).not.toHaveBeenCalled();
expect(DraftEditPostUploadManager.cancel).toHaveBeenCalledWith(TEST_FILES.newFile.clientId);
await waitFor(() => {
expect(DraftEditPostUploadManager.cancel).toHaveBeenCalledWith(TEST_FILES.newFile.clientId);
});
});
it('should show confirmation dialog for existing files', () => {
it('should show confirmation dialog for existing files', async () => {
const screen = renderEditPost();
triggerFileRemoval(screen, TEST_FILES.existingFile1.id);
expect(Alert.alert).toHaveBeenCalledWith(
ERROR_MESSAGES.confirmDelete,
'Are you sure you want to remove test-1?',
expect.any(Array),
);
await triggerFileRemoval(screen, TEST_FILES.existingFile1.id);
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
ERROR_MESSAGES.confirmDelete,
'Are you sure you want to remove test-1?',
expect.any(Array),
);
});
});
});
describe('Error Display', () => {
it('should display upload error in PostError component when file upload fails', () => {
it('should display upload error in PostError component when file upload fails', async () => {
setupPickerMock(TEST_FILES.largeFile);
const props = {...baseProps, maxFileSize: 1000};
const screen = renderEditPost(props);
// Trigger file upload that will cause size error
triggerFileUpload(screen);
await triggerFileUpload(screen);
// Verify that the PostError component is displayed with the error
expect(screen.getByTestId('edit_post.message.input.error')).toBeVisible();
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
await waitFor(() => {
expect(screen.getByTestId('edit_post.message.input.error')).toBeVisible();
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
});
});
it('should display upload error with divider when error is present', () => {
it('should display upload error with divider when error is present', async () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, canUploadFiles: false};
const screen = renderEditPost(props);
// Trigger file upload that will cause upload disabled error
triggerFileUpload(screen);
await triggerFileUpload(screen);
// Verify that the error is displayed
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
});
// Verify that the PostError component has the hasError styling applied
const editPostInput = screen.getByTestId('edit_post.message.input');

View file

@ -4,6 +4,7 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Keyboard, type LayoutChangeEvent, Platform, View, StyleSheet} from 'react-native';
import {KeyboardProvider} from 'react-native-keyboard-controller';
import {SafeAreaView, type Edge} from 'react-native-safe-area-context';
import {deletePost, editPost} from '@actions/remote/post';
@ -123,6 +124,9 @@ const EditPost = ({
useEffect(() => {
toggleSaveButton(false);
// No dependencies to avoid unnecessary re-renders since this is a one-time effect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@ -450,44 +454,88 @@ const EditPost = ({
onLayout={onLayout}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<ExtraKeyboardProvider>
<View
style={styles.body}
ref={mainView}
>
{Boolean((errorLine || errorExtra)) &&
<PostError
errorExtra={errorExtra}
errorLine={errorLine}
/>
}
<View style={styles.inputContainer}>
<EditPostInput
hasError={Boolean(errorLine || errorExtra)}
message={postMessage}
onChangeText={onInputChangeText}
onTextSelectionChange={onTextSelectionChange}
inputRef={postInputRef}
post={post}
postFiles={postFiles}
addFiles={addFiles}
/>
</View>
</View>
</ExtraKeyboardProvider>
{Platform.OS === 'ios' ? (
<KeyboardProvider>
<ExtraKeyboardProvider>
<View
style={styles.body}
ref={mainView}
>
{Boolean((errorLine || errorExtra)) &&
<PostError
errorExtra={errorExtra}
errorLine={errorLine}
/>
}
<View style={styles.inputContainer}>
<EditPostInput
hasError={Boolean(errorLine || errorExtra)}
message={postMessage}
onChangeText={onInputChangeText}
onTextSelectionChange={onTextSelectionChange}
inputRef={postInputRef}
post={post}
postFiles={postFiles}
addFiles={addFiles}
/>
</View>
</View>
</ExtraKeyboardProvider>
<Autocomplete
channelId={post.channelId}
shouldDirectlyReact={false}
nestedScrollEnabled={true}
rootId={post.rootId}
updateValue={onAutocompleteChangeText}
value={postMessage}
cursorPosition={cursorPosition}
position={animatedAutocompletePosition}
availableSpace={animatedAutocompleteAvailableSpace}
serverUrl={serverUrl}
/>
</KeyboardProvider>
) : (
<>
<ExtraKeyboardProvider>
<View
style={styles.body}
ref={mainView}
>
{Boolean((errorLine || errorExtra)) &&
<PostError
errorExtra={errorExtra}
errorLine={errorLine}
/>
}
<View style={styles.inputContainer}>
<EditPostInput
hasError={Boolean(errorLine || errorExtra)}
message={postMessage}
onChangeText={onInputChangeText}
onTextSelectionChange={onTextSelectionChange}
inputRef={postInputRef}
post={post}
postFiles={postFiles}
addFiles={addFiles}
/>
</View>
</View>
</ExtraKeyboardProvider>
<Autocomplete
channelId={post.channelId}
shouldDirectlyReact={false}
nestedScrollEnabled={true}
rootId={post.rootId}
updateValue={onAutocompleteChangeText}
value={postMessage}
cursorPosition={cursorPosition}
position={animatedAutocompletePosition}
availableSpace={animatedAutocompleteAvailableSpace}
serverUrl={serverUrl}
/>
</>
)}
</SafeAreaView>
<Autocomplete
channelId={post.channelId}
shouldDirectlyReact={false}
nestedScrollEnabled={true}
rootId={post.rootId}
updateValue={onAutocompleteChangeText}
value={postMessage}
cursorPosition={cursorPosition}
position={animatedAutocompletePosition}
availableSpace={animatedAutocompleteAvailableSpace}
serverUrl={serverUrl}
/>
</EditPostProvider>
);
};

View file

@ -91,9 +91,7 @@ describe('EditPostInput', () => {
const {queryByTestId} = renderWithEverything(<EditPostInput {...props}/>, {database, serverUrl});
expect(queryByTestId('edit_post.quick_actions')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.at_input_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.file_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.image_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.camera_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.attachment_action')).toBeNull();
});
it('should not render both uploads and quick actions when server version is unsupported', () => {
@ -106,10 +104,8 @@ describe('EditPostInput', () => {
it('should render quick actions in edit mode', () => {
const {getByTestId, queryByTestId} = renderWithEverything(<EditPostInput {...baseProps}/>, {database, serverUrl});
expect(getByTestId('edit_post.quick_actions')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.attachment_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.at_input_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.file_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.image_action')).toBeVisible();
expect(getByTestId('edit_post.quick_actions.camera_action')).toBeVisible();
expect(queryByTestId('edit_post.quick_actions.slash_input_action')).toBeNull();
expect(queryByTestId('edit_post.quick_actions.post_priority_action')).toBeNull();
});

View file

@ -67,6 +67,7 @@ const EditPostInput = ({
const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true';
const focus = useCallback(() => {
inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateValue = useCallback((valueOrUpdater: string | ((prevValue: string) => string)) => {
@ -144,6 +145,7 @@ const EditPostInput = ({
canShowPostPriority={false}
postPriority={INITIAL_PRIORITY}
canShowSlashCommands={false}
canShowEmojiPicker={false}
focus={focus}
/>
</>

View file

@ -1,67 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {useTheme} from '@context/theme';
import {selectEmojiCategoryBarSection, useEmojiCategoryBar} from '@hooks/emoji_category_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import EmojiCategoryBarIcon from './icon';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
justifyContent: 'space-between',
backgroundColor: theme.centerChannelBg,
height: 55,
paddingHorizontal: 12,
paddingTop: 11,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.08),
borderTopWidth: 1,
flexDirection: 'row',
},
}));
type Props = {
onSelect?: (index: number | undefined) => void;
}
const EmojiCategoryBar = ({onSelect}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {currentIndex, icons} = useEmojiCategoryBar();
const scrollToIndex = useCallback((index: number) => {
if (onSelect) {
onSelect(index);
return;
}
selectEmojiCategoryBarSection(index);
}, []);
if (!icons) {
return null;
}
return (
<View
style={styles.container}
testID='emoji_picker.category_bar'
>
{icons.map((icon, index) => (
<EmojiCategoryBarIcon
currentIndex={currentIndex}
key={icon.key}
icon={icon.icon}
index={index}
scrollToIndex={scrollToIndex}
theme={theme}
/>
))}
</View>
);
};
export default EmojiCategoryBar;

View file

@ -6,12 +6,11 @@ import React, {useCallback} from 'react';
import {Platform} from 'react-native';
import Animated, {useAnimatedStyle, withTiming, type SharedValue} from 'react-native-reanimated';
import EmojiCategoryBar from '@components/emoji_category_bar';
import {useTheme} from '@context/theme';
import {useKeyboardHeight} from '@hooks/device';
import {selectEmojiCategoryBarSection} from '@hooks/emoji_category_bar';
import EmojiCategoryBar from '../emoji_category_bar';
function waitForSheetExtended(animatedSheetState: SharedValue<number>, callback: () => void, depth = 250) {
if (animatedSheetState.value === SHEET_STATE.EXTENDED) {
callback();
@ -36,6 +35,9 @@ const PickerFooter = (props: BottomSheetFooterProps) => {
waitForSheetExtended(animatedSheetState, () => {
selectEmojiCategoryBarSection(index);
});
// animatedSheetState and expand are stable and don't need to be in dependencies to avoid unnecessary callback recreation
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const animatedStyle = useAnimatedStyle(() => {

View file

@ -129,6 +129,9 @@ const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tu
setTooltipVisible(true);
}
});
// tutorialWatched is not a dependency because it is not used in the effect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
@ -145,7 +148,7 @@ const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tu
>
<Animated.View
style={widthAnimatedStyle}
exiting={Platform.OS === 'android' ? undefined : FadeOut /* https://mattermost.atlassian.net/browse/MM-63814?focusedCommentId=178584 */}
exiting={Platform.select({ios: FadeOut})} /* https://mattermost.atlassian.net/browse/MM-63814?focusedCommentId=178584 */
entering={FadeIn}
>
<Animated.View style={[styles.container, opacityStyle]}>
@ -162,7 +165,7 @@ const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tu
<Animated.View
style={styles.expanded}
entering={entering}
exiting={exiting}
exiting={Platform.select({ios: exiting})} /* https://mattermost.atlassian.net/browse/MM-63814?focusedCommentId=178584 */
>
{!isTablet && <CloseButton collapse={collapse}/>}
<SkinSelector

View file

@ -71,6 +71,7 @@ export default function EmojiRow({emojis, file, imageUrl, onEmojiPress}: EmojiRo
name={emoji.name}
onEmojiPress={onEmojiPress}
category={emoji.category}
preventDoubleTap={false}
/>
);
})}

View file

@ -8,6 +8,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View, StyleSheet} from 'react-native';
import {fetchCustomEmojis} from '@actions/remote/custom_emoji';
import EmojiCategoryBar from '@components/emoji_category_bar';
import {EMOJI_CATEGORY_ICONS, EMOJI_ROW_MARGIN, EMOJI_SIZE, EMOJIS_PER_PAGE, EMOJIS_PER_ROW, EMOJIS_PER_ROW_TABLET} from '@constants/emoji';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
@ -15,8 +16,6 @@ import {setEmojiCategoryBarIcons, setEmojiCategoryBarSection, useEmojiCategoryBa
import {CategoryNames, EmojiIndicesByCategory, CategoryTranslations, CategoryMessage} from '@utils/emoji';
import {fillEmoji} from '@utils/emoji/helpers';
import EmojiCategoryBar from '../emoji_category_bar';
import EmojiRow, {type EmojiSectionRow} from './emoji_row';
import SectionFooter from './section_footer';
import SectionHeader, {type EmojiSection} from './section_header';

View file

@ -29,9 +29,10 @@ const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
height: SECTION_HEADER_HEIGHT,
justifyContent: 'center',
backgroundColor: theme.centerChannelBg,
paddingHorizontal: 12,
},
sectionTitle: {
color: changeOpacity(theme.centerChannelColor, 0.2),
color: changeOpacity(theme.centerChannelColor, 0.56),
textTransform: 'uppercase',
...typography('Heading', 75, 'SemiBold'),
},

View file

@ -157,6 +157,10 @@ const ChannelListScreen = (props: ChannelProps) => {
if (!props.hasCurrentUser || !props.currentUserId) {
refetchCurrentUser(serverUrl, props.currentUserId);
}
// - serverUrl is stable from useServerUrl hook
// - We only need to re-run when the current user state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.currentUserId, props.hasCurrentUser]);
// Init the rate app. Only run the effect on the first render if ToS is not open
@ -168,12 +172,12 @@ const ChannelListScreen = (props: ChannelProps) => {
if (!NavigationStore.isToSOpen()) {
tryRunAppReview(props.launchType, props.coldStart);
}
}, []);
}, [props.launchType, props.coldStart]);
useEffect(() => {
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
PerformanceMetricsManager.measureTimeToInteraction();
}, []);
}, [serverUrl]);
return (
<>

View file

@ -7,6 +7,7 @@ import {NavigationContainer, DefaultTheme} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native';
import {useKeyboardState} from 'react-native-keyboard-controller';
import {enableFreeze, enableScreens} from 'react-native-screens';
import {autoUpdateTimezone} from '@actions/remote/user';
@ -66,11 +67,18 @@ export function HomeScreen(props: HomeProps) {
const theme = useTheme();
const intl = useIntl();
const appState = useAppState();
const keyboardState = useKeyboardState();
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = React.useState(false);
useEffect(() => {
SecurityManager.start();
}, []);
useEffect(() => {
// Hide tab bar when keyboard opens, show when it closes
DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, !keyboardState.isVisible);
}, [keyboardState.isVisible]);
const handleFindChannels = useCallback(() => {
if (!NavigationStore.getScreensInStack().includes(Screens.FIND_CHANNELS)) {
findChannels(
@ -147,6 +155,28 @@ export function HomeScreen(props: HomeProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.EMOJI_PICKER_SEARCH_FOCUSED, (focused: boolean) => {
setIsEmojiSearchFocused(focused);
});
return () => listener.remove();
}, []);
const TabBarComponent = (tabProps: BottomTabBarProps) => {
if (isEmojiSearchFocused) {
return null;
}
return (
<TabBar
{...tabProps}
theme={theme}
/>
);
};
TabBarComponent.displayName = 'TabBarComponent';
return (
<View
style={styles.flex}
@ -170,11 +200,7 @@ export function HomeScreen(props: HomeProps) {
<Tab.Navigator
screenOptions={{headerShown: false, freezeOnBlur: false, lazy: true}}
backBehavior='none'
tabBar={(tabProps: BottomTabBarProps) => (
<TabBar
{...tabProps}
theme={theme}
/>)}
tabBar={TabBarComponent}
>
<Tab.Screen
name={Screens.HOME}

View file

@ -67,6 +67,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.APPS_FORM:
screen = withServerDatabase(require('@screens/apps_form').default);
break;
case Screens.ATTACHMENT_OPTIONS:
screen = withServerDatabase(require('@screens/attachment_options').default);
break;
case Screens.BOTTOM_SHEET:
screen = withServerDatabase(require('@screens/bottom_sheet').default);
break;

View file

@ -5,12 +5,29 @@ import {Navigation} from 'react-native-navigation';
import {Events, Preferences, Screens} from '@constants';
import NavigationStore from '@store/navigation_store';
import {isTablet} from '@utils/helpers';
import {openAsBottomSheet} from './navigation';
import {openAsBottomSheet, openAttachmentOptions} from './navigation';
import type {FirstArgument} from '@typings/utils/utils';
import type {IntlShape} from 'react-intl';
jest.mock('@utils/helpers', () => ({
...jest.requireActual('@utils/helpers'),
isTablet: jest.fn(),
}));
jest.mock('@components/compass_icon', () => {
function CompassIcon() {
return null;
}
CompassIcon.getImageSourceSync = jest.fn().mockReturnValue({});
return {
__esModule: true,
default: CompassIcon,
};
});
function expectShowModalCalledWith(screen: string, title: string, props?: Record<string, unknown>) {
expect(Navigation.showModal).toHaveBeenCalledWith({
stack: {
@ -33,8 +50,8 @@ function expectShowModalOverCurrentContext(screen: string, props?: Record<string
expectShowModalCalledWith(screen, '', props);
}
function expectOpenAsBottomSheetCalledWith(props: FirstArgument<typeof openAsBottomSheet>, isTablet: boolean) {
if (isTablet) {
function expectOpenAsBottomSheetCalledWith(props: FirstArgument<typeof openAsBottomSheet>, isTabletDevice: boolean) {
if (isTabletDevice) {
expectShowModalCalledWith(props.screen, props.title, {closeButtonId: props.closeButtonId, ...props.props});
} else {
expectShowModalOverCurrentContext(props.screen, props.props);
@ -107,3 +124,68 @@ describe('openUserProfileModal', () => {
}, false);
});
});
describe('openAttachmentOptions', () => {
const intl = {
formatMessage: jest.fn(({defaultMessage}) => defaultMessage),
} as unknown as IntlShape;
const theme = Preferences.THEMES.denim;
const mockOnUploadFiles = jest.fn();
const props = {
onUploadFiles: mockOnUploadFiles,
maxFilesReached: false,
canUploadFiles: true,
testID: 'test-attachment',
fileCount: 0,
maxFileCount: 5,
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(isTablet).mockReturnValue(false);
});
it('should call openAsBottomSheet with correct parameters on non-tablet', () => {
openAttachmentOptions(intl, theme, props);
expectOpenAsBottomSheetCalledWith({
screen: Screens.ATTACHMENT_OPTIONS,
title: 'Files and media',
closeButtonId: 'attachment-close-id',
theme,
props,
}, false);
});
it('should call openAsBottomSheet with correct parameters on tablet', () => {
jest.mocked(isTablet).mockReturnValue(true);
openAttachmentOptions(intl, theme, props);
expectOpenAsBottomSheetCalledWith({
screen: Screens.ATTACHMENT_OPTIONS,
title: 'Files and media',
closeButtonId: 'attachment-close-id',
theme,
props,
}, true);
});
it('should handle optional props correctly', () => {
const minimalProps = {
onUploadFiles: mockOnUploadFiles,
maxFilesReached: true,
canUploadFiles: false,
};
openAttachmentOptions(intl, theme, minimalProps);
expectOpenAsBottomSheetCalledWith({
screen: Screens.ATTACHMENT_OPTIONS,
title: 'Files and media',
closeButtonId: 'attachment-close-id',
theme,
props: minimalProps,
}, false);
});
});

View file

@ -26,6 +26,7 @@ import type {LaunchProps} from '@typings/launch';
import type {AvailableScreens, NavButtons} from '@typings/screens/navigation';
import type {ComponentProps} from 'react';
import type {IntlShape} from 'react-intl';
import type {Asset} from 'react-native-image-picker';
const alpha = {
from: 0,
@ -913,6 +914,28 @@ export function openAsBottomSheet({closeButtonId, screen, theme, title, props}:
}
}
export function openAttachmentOptions(
intl: IntlShape,
theme: Theme,
props: {
onUploadFiles: (files: Asset[]) => void;
maxFilesReached: boolean;
canUploadFiles: boolean;
testID?: string;
fileCount?: number;
maxFileCount?: number;
},
) {
const title = intl.formatMessage({id: 'mobile.file_attachment.title', defaultMessage: 'Files and media'});
openAsBottomSheet({
closeButtonId: 'attachment-close-id',
screen: Screens.ATTACHMENT_OPTIONS,
theme,
title,
props,
});
}
export const showAppForm = async (form: AppForm, context: AppContext) => {
const passProps = {form, context};
showModal(Screens.APPS_FORM, form.title || '', passProps);

View file

@ -3,7 +3,8 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert, Text, TouchableOpacity, View} from 'react-native';
import {Alert, Platform, Text, TouchableOpacity, View} from 'react-native';
import {KeyboardProvider} from 'react-native-keyboard-controller';
import Animated from 'react-native-reanimated';
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
@ -17,7 +18,6 @@ import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import PostList from '@components/post_list';
import {Screens} from '@constants';
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
@ -263,6 +263,12 @@ function Permalink({
});
setLoading(false);
})();
// - serverUrl is stable from useServerUrl hook (doesn't need to be in deps)
// - postId, isTeamMember, currentTeamId are props that don't change for a given permalink screen
// - setError, setLoading, setChannelId, setPosts are stable setState functions
// - We only need to re-run when channelId, rootId, isCRTEnabled, or teamName changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelId, rootId, isCRTEnabled, teamName]);
const handleClose = useCallback(() => {
@ -314,8 +320,8 @@ function Permalink({
/>
);
} else {
content = (
<ExtraKeyboardProvider>
const postListContent = (
<>
<View style={style.postList}>
<PostList
highlightedId={postId}
@ -327,7 +333,6 @@ function Permalink({
channelId={channel!.id}
rootId={rootId}
testID='permalink.post_list'
nativeID={Screens.PERMALINK}
highlightPinnedOrSaved={false}
/>
</View>
@ -340,7 +345,15 @@ function Permalink({
testID='permalink.jump_to_recent_messages.button'
/>
</View>
</ExtraKeyboardProvider>
</>
);
content = Platform.OS === 'ios' ? (
<KeyboardProvider>
{postListContent}
</KeyboardProvider>
) : (
postListContent
);
}

View file

@ -2,26 +2,26 @@
// See LICENSE.txt for license information.
import {uniqueId} from 'lodash';
import React, {useCallback, useEffect, useState} from 'react';
import {type LayoutChangeEvent, StyleSheet, View} from 'react-native';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, type LayoutChangeEvent, StyleSheet} from 'react-native';
import {KeyboardProvider} from 'react-native-keyboard-controller';
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {storeLastViewedThreadIdAndServer, removeLastViewedThreadIdAndServer} from '@actions/app/global';
import FloatingCallContainer from '@calls/components/floating_call_container';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
import RoundedHeaderContext from '@components/rounded_header_context';
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
import {Screens} from '@constants';
import {ExtraKeyboardProvider} from '@context/extra_keyboard';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useIsScreenVisible} from '@hooks/use_screen_visibility';
import SecurityManager from '@managers/security_manager';
import {popTopScreen, setButtons} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import ThreadPostList from './thread_post_list';
import ThreadContent from './thread_content';
import type PostModel from '@typings/database/models/servers/post';
import type {AvailableScreens} from '@typings/screens/navigation';
@ -37,8 +37,6 @@ type ThreadProps = {
scheduledPostCount: number;
};
const edges: Edge[] = ['left', 'right'];
const styles = StyleSheet.create({
flex: {flex: 1},
});
@ -54,6 +52,20 @@ const Thread = ({
scheduledPostCount,
}: ThreadProps) => {
const [containerHeight, setContainerHeight] = useState(0);
const isVisible = useIsScreenVisible(componentId);
const isTablet = useIsTablet();
const [isEmojiSearchFocused, setIsEmojiSearchFocused] = useState(false);
// Remove bottom safe area when emoji search is focused to eliminate gap between emoji picker and keyboard
const safeAreaViewEdges: Edge[] = useMemo(() => {
if (isTablet) {
return ['left', 'right'];
}
if (isEmojiSearchFocused) {
return ['left', 'right'];
}
return ['left', 'right', 'bottom'];
}, [isTablet, isEmojiSearchFocused]);
const close = useCallback(() => {
popTopScreen(componentId);
@ -96,7 +108,7 @@ const Thread = ({
}
setButtons(componentId, {rightButtons: []});
};
}, [rootId]);
}, [rootId, componentId, isCRTEnabled]);
useDidUpdate(() => {
if (!rootPost) {
@ -115,37 +127,34 @@ const Thread = ({
<SafeAreaView
style={styles.flex}
mode='margin'
edges={edges}
edges={safeAreaViewEdges}
testID='thread.screen'
onLayout={onLayout}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<RoundedHeaderContext/>
{Boolean(rootPost) &&
<ExtraKeyboardProvider>
<View style={styles.flex}>
<ThreadPostList
nativeID={rootId}
(Platform.OS === 'ios' ? (
<KeyboardProvider>
<ThreadContent
rootId={rootId}
rootPost={rootPost!}
scheduledPostCount={scheduledPostCount}
containerHeight={containerHeight}
enabled={isVisible}
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
/>
</View>
<>
{scheduledPostCount > 0 &&
<ScheduledPostIndicator
isThread={true}
scheduledPostCount={scheduledPostCount}
/>
}
</>
<PostDraft
channelId={rootPost!.channelId}
</KeyboardProvider>
) : (
<ThreadContent
rootId={rootId}
testID='thread.post_draft'
rootPost={rootPost!}
scheduledPostCount={scheduledPostCount}
containerHeight={containerHeight}
isChannelScreen={false}
location={Screens.THREAD}
enabled={isVisible}
onEmojiSearchFocusChange={setIsEmojiSearchFocused}
/>
</ExtraKeyboardProvider>
))
}
{showFloatingCallContainer &&
<FloatingCallContainer

View file

@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PortalProvider} from '@gorhom/portal';
import React from 'react';
import {StyleSheet} from 'react-native';
import {KeyboardAwarePostDraftContainer} from '@components/keyboard_aware_post_draft_container';
import PostDraft from '@components/post_draft';
import ScheduledPostIndicator from '@components/scheduled_post_indicator';
import {Screens} from '@constants';
import ThreadPostList from './thread_post_list';
import type PostModel from '@typings/database/models/servers/post';
type ThreadContentProps = {
rootId: string;
rootPost: PostModel;
scheduledPostCount: number;
containerHeight: number;
enabled?: boolean;
onEmojiSearchFocusChange?: (focused: boolean) => void;
}
const THREAD_POST_DRAFT_TESTID = 'thread.post_draft';
// This follows the same pattern as draft_input.tsx: `${testID}.post.input`
const THREAD_POST_INPUT_NATIVE_ID = `${THREAD_POST_DRAFT_TESTID}.post.input`;
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
const ThreadContent = ({
rootId,
rootPost,
scheduledPostCount,
containerHeight,
enabled = true,
onEmojiSearchFocusChange,
}: ThreadContentProps) => {
return (
<PortalProvider>
<KeyboardAwarePostDraftContainer
textInputNativeID={THREAD_POST_INPUT_NATIVE_ID}
containerStyle={styles.flex}
isThreadView={true}
enabled={enabled}
onEmojiSearchFocusChange={onEmojiSearchFocusChange}
renderList={({listRef, onTouchMove, onTouchEnd}) => (
<ThreadPostList
rootPost={rootPost}
listRef={listRef}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
)}
>
{scheduledPostCount > 0 &&
<ScheduledPostIndicator
isThread={true}
scheduledPostCount={scheduledPostCount}
/>
}
<PostDraft
channelId={rootPost.channelId}
rootId={rootId}
testID={THREAD_POST_DRAFT_TESTID}
containerHeight={containerHeight}
isChannelScreen={false}
location={Screens.THREAD}
/>
</KeyboardAwarePostDraftContainer>
</PortalProvider>
);
};
export default ThreadContent;

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {ActivityIndicator, StyleSheet, View} from 'react-native';
import {ActivityIndicator, type FlatList, type GestureResponderEvent, StyleSheet, View} from 'react-native';
import {fetchPostThread} from '@actions/remote/post';
import {markThreadAsRead} from '@actions/remote/thread';
@ -22,12 +22,14 @@ import type ThreadModel from '@typings/database/models/servers/thread';
type Props = {
channelLastViewedAt: number;
isCRTEnabled: boolean;
nativeID: string;
posts: PostModel[];
rootPost: PostModel;
teamId: string;
thread?: ThreadModel;
version?: string;
listRef: React.RefObject<FlatList<string | PostModel>>;
onTouchMove?: (event: GestureResponderEvent) => void;
onTouchEnd?: () => void;
}
const styles = StyleSheet.create({
@ -38,7 +40,8 @@ const styles = StyleSheet.create({
const ThreadPostList = ({
channelLastViewedAt, isCRTEnabled,
nativeID, posts, rootPost, teamId, thread, version,
posts, rootPost, teamId, thread, version,
listRef, onTouchMove, onTouchEnd,
}: Props) => {
const appState = useAppState();
const serverUrl = useServerUrl();
@ -103,7 +106,6 @@ const ThreadPostList = ({
isCRTEnabled={isCRTEnabled}
lastViewedAt={lastViewedAt}
location={Screens.THREAD}
nativeID={nativeID}
onEndReached={onEndReached}
posts={threadPosts}
rootId={rootPost.id}
@ -112,6 +114,9 @@ const ThreadPostList = ({
header={header}
footer={<View style={styles.footer}/>}
testID='thread.post_list'
listRef={listRef}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
);

View file

@ -83,6 +83,13 @@ class NavigationStoreSingleton {
if (index > -1) {
this.modalsInStack.splice(index, 1);
}
// Restore the previous screen as visible when modal is dismissed
// This ensures that the underlying screen (e.g., Channel/Thread) becomes enabled again
const visibleScreen = this.screensInStack[0];
if (visibleScreen) {
this.subject.next(visibleScreen);
}
};
setToSOpen = (open: boolean) => {

163
app/utils/grapheme.test.ts Normal file
View file

@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {deleteLastGrapheme} from './grapheme';
describe('deleteLastGrapheme', () => {
describe('Simple ASCII characters', () => {
it('should delete a single character', () => {
const result = deleteLastGrapheme('Hello', 5);
expect(result.text).toBe('Hell');
expect(result.cursorPosition).toBe(4);
});
it('should delete character in the middle', () => {
const result = deleteLastGrapheme('Hello World', 5);
expect(result.text).toBe('Hell World');
expect(result.cursorPosition).toBe(4);
});
it('should delete character at the start', () => {
const result = deleteLastGrapheme('Hello', 1);
expect(result.text).toBe('ello');
expect(result.cursorPosition).toBe(0);
});
});
describe('Basic emojis', () => {
it('should delete a simple emoji', () => {
const result = deleteLastGrapheme('Hello 😀', 8);
expect(result.text).toBe('Hello ');
expect(result.cursorPosition).toBe(6);
});
it('should delete emoji in the middle', () => {
const result = deleteLastGrapheme('😀 World', 2);
expect(result.text).toBe(' World');
expect(result.cursorPosition).toBe(0);
});
it('should delete emoji at the start', () => {
const result = deleteLastGrapheme('😀 Hello', 2);
expect(result.text).toBe(' Hello');
expect(result.cursorPosition).toBe(0);
});
it('should delete multiple emojis correctly', () => {
const result = deleteLastGrapheme('😀❤️🎉', 6);
expect(result.text).toBe('😀❤️');
expect(result.cursorPosition).toBe(4);
});
});
describe('Complex Unicode characters', () => {
it('should delete flag emoji (🇺🇸)', () => {
const result = deleteLastGrapheme('Hello 🇺🇸', 10);
expect(result.text).toBe('Hello ');
expect(result.cursorPosition).toBe(6);
});
it('should delete flag emoji at start', () => {
const result = deleteLastGrapheme('🇺🇸 Hello', 4);
expect(result.text).toBe(' Hello');
expect(result.cursorPosition).toBe(0);
});
it('should delete family emoji (👨‍👩‍👧‍👦)', () => {
const result = deleteLastGrapheme('Family 👨‍👩‍👧‍👦', 18);
expect(result.text).toBe('Family ');
expect(result.cursorPosition).toBe(7);
});
it('should delete emoji with skin tone modifier (👨🏿)', () => {
const result = deleteLastGrapheme('Hello 👨🏿', 10);
expect(result.text).toBe('Hello ');
expect(result.cursorPosition).toBe(6);
});
it('should delete emoji with zero-width joiner', () => {
const result = deleteLastGrapheme('👨‍👩‍👧‍👦', 11);
expect(result.text).toBe('');
expect(result.cursorPosition).toBe(0);
});
});
describe('Mixed text and emojis', () => {
it('should delete emoji from mixed content', () => {
const result = deleteLastGrapheme('Hello 😀 World', 8);
expect(result.text).toBe('Hello World');
expect(result.cursorPosition).toBe(6);
});
it('should delete text character before emoji', () => {
const result = deleteLastGrapheme('Hello😀', 5);
expect(result.text).toBe('Hell😀');
expect(result.cursorPosition).toBe(4);
});
it('should delete emoji between text', () => {
const result = deleteLastGrapheme('Hello 😀 World', 8);
expect(result.text).toBe('Hello World');
expect(result.cursorPosition).toBe(6);
});
});
describe('Edge cases', () => {
it('should handle empty string', () => {
const result = deleteLastGrapheme('', 0);
expect(result.text).toBe('');
expect(result.cursorPosition).toBe(0);
});
it('should handle cursor at start', () => {
const result = deleteLastGrapheme('Hello', 0);
expect(result.text).toBe('Hello');
expect(result.cursorPosition).toBe(0);
});
it('should handle cursor beyond text length', () => {
const result = deleteLastGrapheme('Hello', 10);
expect(result.text).toBe('Hell');
expect(result.cursorPosition).toBe(4);
});
it('should handle single character', () => {
const result = deleteLastGrapheme('H', 1);
expect(result.text).toBe('');
expect(result.cursorPosition).toBe(0);
});
it('should handle single emoji', () => {
const result = deleteLastGrapheme('😀', 2);
expect(result.text).toBe('');
expect(result.cursorPosition).toBe(0);
});
it('should handle only whitespace', () => {
const result = deleteLastGrapheme(' ', 2);
expect(result.text).toBe(' ');
expect(result.cursorPosition).toBe(1);
});
});
describe('Real-world scenarios', () => {
it('should handle message with emojis', () => {
const result = deleteLastGrapheme('Great job! 🎉👏', 15);
expect(result.text).toBe('Great job! 🎉');
expect(result.cursorPosition).toBe(13);
});
it('should handle message with flag', () => {
const result = deleteLastGrapheme('From 🇺🇸', 9);
expect(result.text).toBe('From ');
expect(result.cursorPosition).toBe(5);
});
it('should handle message with mixed content', () => {
const result = deleteLastGrapheme('Hello 👨‍👩‍👧‍👦 family!', 17);
expect(result.text).toBe('Hello family!');
expect(result.cursorPosition).toBe(6);
});
});
});

43
app/utils/grapheme.ts Normal file
View file

@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import GraphemeSplitter from 'grapheme-splitter';
/**
* Deletes the last grapheme cluster (character) from a string before the cursor position.
* Handles complex Unicode characters like emojis, flags, and modifiers correctly.
*
* @param text - The text to delete from
* @param cursorPosition - The cursor position (0-based index)
* @returns Object with updated text and new cursor position
*/
export function deleteLastGrapheme(text: string, cursorPosition: number): {
text: string;
cursorPosition: number;
} {
if (cursorPosition === 0) {
return {text, cursorPosition: 0};
}
const adjustedCursorPosition = cursorPosition > text.length ? text.length : cursorPosition;
const splitter = new GraphemeSplitter();
const textBeforeCursor = text.slice(0, adjustedCursorPosition);
const clusters = splitter.splitGraphemes(textBeforeCursor);
if (clusters.length === 0) {
return {text, cursorPosition};
}
// Remove last grapheme cluster
clusters.pop();
const joinedClusters = clusters.join('');
const updatedText = joinedClusters + text.slice(adjustedCursorPosition);
const newCursorPosition = joinedClusters.length;
return {
text: updatedText,
cursorPosition: newCursorPosition,
};
}

32
app/utils/keyboard.ts Normal file
View file

@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Keyboard, Platform} from 'react-native';
import {KeyboardController} from 'react-native-keyboard-controller';
/**
* Dismisses the keyboard using platform-specific implementation.
* - iOS: Uses KeyboardController.dismiss() which provides better control
* - Android: Uses React Native's Keyboard.dismiss() since KeyboardProvider
* is not used on Android (to avoid layout issues)
*/
export const dismissKeyboard = async (): Promise<void> => {
if (Platform.OS === 'ios') {
await KeyboardController.dismiss();
} else {
Keyboard.dismiss();
}
};
/**
* Checks if the keyboard is currently visible.
* - iOS: Uses KeyboardController.isVisible() for accurate keyboard state
* - Android: Uses React Native's Keyboard.isVisible() since KeyboardProvider
* is not used on Android (to avoid layout issues)
*/
export const isKeyboardVisible = (): boolean => {
if (Platform.OS === 'ios') {
return KeyboardController.isVisible();
}
return Keyboard.isVisible();
};

View file

@ -113,8 +113,6 @@
"browse_channels.title": "Browse channels",
"burn_on_read.label.title": "BURN ON READ ({duration})",
"calls.join_call_avatars.bottom_sheet_title": "Call participants",
"camera_type.photo.option": "Capture Photo",
"camera_type.video.option": "Record Video",
"center_panel.archived.closeChannel": "Close Channel",
"channel_add_members.add_members.button": "Add Members",
"channel_bookmark_add.link": "Link",
@ -296,6 +294,7 @@
"create_post.deactivated": "You are viewing an archived channel with a deactivated user.",
"create_post.thread_reply": "Reply to this thread...",
"create_post.write": "Write to {channelDisplayName}",
"custom_emoji_picker.search.no_results": "No results",
"custom_status.expiry_dropdown.custom": "Custom",
"custom_status.expiry_dropdown.date_and_time": "Date and Time",
"custom_status.expiry_dropdown.dont_clear": "Don't clear",
@ -655,7 +654,6 @@
"mobile.calls_you_2": "You",
"mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.",
"mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera",
"mobile.camera_type.title": "Camera options",
"mobile.channel_add_members.error": "There has been an error and we could not add those users to the channel.",
"mobile.channel_info.alertNo": "No",
"mobile.channel_info.alertYes": "Yes",
@ -707,7 +705,12 @@
"mobile.error_handler.button": "Relaunch",
"mobile.error_handler.description": "\nTap relaunch to open the app again. After restart, you can report the problem from the settings menu.\n",
"mobile.error_handler.title": "Unexpected error occurred",
"mobile.file_attachment.title": "Files and media",
"mobile.file_upload.browse": "Attach a file",
"mobile.file_upload.camera_photo": "Take a photo",
"mobile.file_upload.camera_video": "Take a video",
"mobile.file_upload.disabled2": "File uploads from mobile are disabled.",
"mobile.file_upload.library": "Choose from photo library",
"mobile.file_upload.max_warning": "Uploads limited to {count} files maximum.",
"mobile.files_paste.error_description": "An error occurred while pasting the file(s). Please try again.",
"mobile.files_paste.error_dismiss": "Dismiss",

View file

@ -26,4 +26,7 @@ SENTRY_ORG=mattermost-mr
SENTRY_PROJECT_IOS=mattermost-mobile-ios
SHOW_ONBOARDING=true
SHOW_REVIEW=true
SYNC_PROVISIONING_PROFILES=true
SYNC_PROVISIONING_PROFILES=true
INTUNE_ENABLED=true
INTUNE_CLIENT_ID="9ac649f1-4f77-44d6-9792-b2f54ab3c9a7"

View file

@ -569,7 +569,8 @@ platform :ios do
"ADALLogOverrideDisabled" => true,
"HooksAlwaysEnabled" => true,
"ADALClientId" => intune_client_id,
"ADALRedirectUri" => intune_redirect_uri
"ADALRedirectUri" => intune_redirect_uri,
"MaxFileProtectionLevel" => "NSFileProtectionCompleteUntilFirstUserAuthentication"
}
plist["LSApplicationQueriesSchemes"] = [

View file

@ -2077,12 +2077,12 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 701;
CURRENT_PROJECT_VERSION = 717;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = "$(inherited)";
INFOPLIST_FILE = Mattermost/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2119,12 +2119,12 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 701;
CURRENT_PROJECT_VERSION = 717;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = "$(inherited)";
INFOPLIST_FILE = Mattermost/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2175,7 +2175,7 @@
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = NotificationService/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2227,7 +2227,7 @@
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = NotificationService/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2271,13 +2271,13 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 701;
CURRENT_PROJECT_VERSION = 717;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = MattermostShare/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2324,13 +2324,13 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 701;
CURRENT_PROJECT_VERSION = 717;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = MattermostShare/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2403,7 +2403,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
);
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD = "";
LDPLUSPLUS = "";
MTL_ENABLE_DEBUG_INFO = YES;
@ -2476,7 +2476,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
);
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD = "";
LDPLUSPLUS = "";
MTL_ENABLE_DEBUG_INFO = NO;

View file

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.36.0</string>
<string>2.37.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -37,7 +37,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>701</string>
<string>717</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.36.0</string>
<string>2.37.0</string>
<key>CFBundleVersion</key>
<string>701</string>
<string>717</string>
<key>UIAppFonts</key>
<array>
<string>OpenSans-Bold.ttf</string>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.36.0</string>
<string>2.37.0</string>
<key>CFBundleVersion</key>
<string>701</string>
<string>717</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View file

@ -12,7 +12,7 @@ require File.join(File.dirname(`node --print "require.resolve('expo/package.json
node_require('react-native/scripts/react_native_pods.rb')
node_require('react-native-permissions/scripts/setup.rb')
platform :ios, '15.1' #min_ios_version_supported
platform :ios, '16.0' #min_ios_version_supported
prepare_react_native_project!
Pod::UI.puts "Configuring Pod with statically linked Frameworks".green
@ -26,7 +26,7 @@ setup_permissions([
])
# Conditional Intune MAM support
intune_enabled = ENV['INTUNE_ENABLED'] == '1'
intune_enabled = ENV['INTUNE_ENABLED'] == 'true' || ENV['INTUNE_ENABLED'] == '1'
if intune_enabled
puts "🔐 Intune MAM features enabled"

View file

@ -1433,6 +1433,27 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-keyboard-controller (1.19.3):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-netinfo (11.4.1):
- React-Core
- react-native-network-client (1.9.1):
@ -2251,6 +2272,7 @@ DEPENDENCIES:
- react-native-document-picker (from `../node_modules/react-native-document-picker`)
- "react-native-emm (from `../node_modules/@mattermost/react-native-emm`)"
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
- react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- "react-native-network-client (from `../node_modules/@mattermost/react-native-network-client`)"
- react-native-notifications (from `../node_modules/react-native-notifications`)
@ -2455,6 +2477,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@mattermost/react-native-emm"
react-native-image-picker:
:path: "../node_modules/react-native-image-picker"
react-native-keyboard-controller:
:path: "../node_modules/react-native-keyboard-controller"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-network-client:
@ -2652,6 +2676,7 @@ SPEC CHECKSUMS:
react-native-document-picker: 3ffdb92f95177227477077cfebabf429ad4e73a4
react-native-emm: 0873c06dc36ac2b68b398726e5a8e2a8d47da929
react-native-image-picker: 1279ed5533ab248197e8a05f928f3d826fe5a31e
react-native-keyboard-controller: 0aff65ca209dae10cd0ac7c9c2404baabb625d42
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-network-client: 209fc0c5b0bd7e367825fb9ca9bfa610ab5ec797
react-native-notifications: 3bafa1237ae8a47569a84801f17d80242fe9f6a5
@ -2721,6 +2746,6 @@ SPEC CHECKSUMS:
WatermelonDB: 4c846c8cb94eef3cba90fa034d15310163226703
Yoga: 2b02f3f767761bb4ffd25b1bb56fd264be57bd6b
PODFILE CHECKSUM: 6c4ca8922ebba245d14c717b0a7063d2bd56acad
PODFILE CHECKSUM: f3442fc727bf29ef7e4eda969e4e598128801d4a
COCOAPODS: 1.16.1

@ -1 +1 @@
Subproject commit 412afe596e58c973673366c101ba309a21d115f0
Subproject commit 9a888c921b339c5f268ad202b6e498ca1678c82e

Some files were not shown because too many files have changed in this diff Show more