From afd818996e6c755fb3727d642779e8e03fa48f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Sat, 13 Aug 2022 14:34:26 +0200 Subject: [PATCH] Improve autocomplete behaviour (#6559) * Fix positioning issues with Autocomplete * Fix positioning for iOS and iPad * Adapt search to new autocomplete approach * Adapt for android * Fix lint * Fix calculations on channel edit * Address feedback * Address feedback Co-authored-by: Daniel Espino --- app/components/autocomplete/autocomplete.tsx | 57 +++----- .../emoji_suggestion/emoji_suggestion.tsx | 4 +- app/components/option_item/index.tsx | 5 +- app/components/post_draft/post_draft.tsx | 26 +++- app/constants/autocomplete.ts | 9 +- app/hooks/device.ts | 62 ++++++++- app/screens/channel/channel.tsx | 12 +- .../channel_info_form.tsx | 127 +++++++++++++----- app/screens/edit_post/edit_post.tsx | 111 ++++++--------- app/screens/home/search/search.tsx | 32 ++++- app/screens/thread/thread.tsx | 26 +++- .../react-native-keyboard-tracking-view.d.ts | 7 + 12 files changed, 303 insertions(+), 175 deletions(-) diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx index 050e83ae7..bbced252b 100644 --- a/app/components/autocomplete/autocomplete.tsx +++ b/app/components/autocomplete/autocomplete.tsx @@ -3,9 +3,8 @@ import React, {useMemo, useState} from 'react'; import {Platform, useWindowDimensions, View} from 'react-native'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import {LIST_BOTTOM, MAX_LIST_DIFF, MAX_LIST_HEIGHT, MAX_LIST_TABLET_DIFF} from '@constants/autocomplete'; +import {MAX_LIST_HEIGHT, MAX_LIST_TABLET_DIFF} from '@constants/autocomplete'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -54,11 +53,9 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { type Props = { cursorPosition: number; - postInputTop: number; - paddingTop?: number; + position: number; rootId?: string; channelId?: string; - fixedBottomPosition?: boolean; isSearch?: boolean; value: string; enableDateSuggestion?: boolean; @@ -66,20 +63,19 @@ type Props = { nestedScrollEnabled?: boolean; updateValue: (v: string) => void; hasFilesAttached?: boolean; - maxHeightOverride?: number; + availableSpace: number; inPost?: boolean; + growDown?: boolean; } const Autocomplete = ({ cursorPosition, - postInputTop, - paddingTop, + position, rootId, channelId, isSearch = false, - fixedBottomPosition, value, - maxHeightOverride, + availableSpace, //enableDateSuggestion = false, isAppsEnabled, @@ -87,12 +83,12 @@ const Autocomplete = ({ updateValue, hasFilesAttached, inPost = false, + growDown = false, }: Props) => { const theme = useTheme(); const isTablet = useIsTablet(); - const dimensions = useWindowDimensions(); const style = getStyleFromTheme(theme); - const insets = useSafeAreaInsets(); + const dimensions = useWindowDimensions(); const [showingAtMention, setShowingAtMention] = useState(false); const [showingChannelMention, setShowingChannelMention] = useState(false); @@ -106,44 +102,31 @@ const Autocomplete = ({ const appsTakeOver = showingAppCommand; const showCommands = !(showingChannelMention || showingEmoji || showingAtMention); - const maxListHeight = useMemo(() => { - if (maxHeightOverride) { - return maxHeightOverride; - } - const isLandscape = dimensions.width > dimensions.height; - let postInputDiff = 0; - if (isTablet && postInputTop && isLandscape) { - postInputDiff = MAX_LIST_TABLET_DIFF; - } else if (postInputTop) { - postInputDiff = MAX_LIST_DIFF; - } - return MAX_LIST_HEIGHT - postInputDiff; - }, [maxHeightOverride, postInputTop, isTablet, dimensions.width]); - const wrapperStyles = useMemo(() => { const s = []; if (Platform.OS === 'ios') { s.push(style.shadow); } - if (isSearch) { - s.push(style.base, paddingTop ? {top: paddingTop} : style.searchContainer, {maxHeight: maxListHeight}); - } return s; - }, [style, isSearch && maxListHeight, paddingTop]); + }, [style]); const containerStyles = useMemo(() => { - const s = []; - if (!isSearch && !fixedBottomPosition) { - const iOSInsets = Platform.OS === 'ios' && (!isTablet || rootId) ? insets.bottom : 0; - s.push(style.base, {bottom: postInputTop + LIST_BOTTOM + iOSInsets}); - } else if (fixedBottomPosition) { - s.push(style.base, {bottom: 0}); + const s = [style.base]; + if (growDown) { + s.push({top: -position}); + } else { + s.push({bottom: position}); } if (hasElements) { s.push(style.borders); } return s; - }, [!isSearch, isTablet, hasElements, postInputTop]); + }, [hasElements, position, growDown, style]); + + const isLandscape = dimensions.width > dimensions.height; + const maxHeightAdjust = (isTablet && isLandscape) ? MAX_LIST_TABLET_DIFF : 0; + const defaultMaxHeight = MAX_LIST_HEIGHT - maxHeightAdjust; + const maxListHeight = Math.min(availableSpace, defaultMaxHeight); return ( { const insets = useSafeAreaInsets(); const theme = useTheme(); diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index 3daa260e2..75ba0477a 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useMemo} from 'react'; -import {Platform, StyleProp, Switch, Text, TextStyle, TouchableOpacity, View, ViewStyle} from 'react-native'; +import {LayoutChangeEvent, Platform, StyleProp, Switch, Text, TextStyle, TouchableOpacity, View, ViewStyle} from 'react-native'; import CompassIcon from '@components/compass_icon'; import TouchableWithFeedback from '@components/touchable_with_feedback'; @@ -114,6 +114,7 @@ export type OptionItemProps = { testID?: string; type: OptionType; value?: string; + onLayout?: (event: LayoutChangeEvent) => void; } const OptionItem = ({ @@ -134,6 +135,7 @@ const OptionItem = ({ testID = 'optionItem', type, value, + onLayout, }: OptionItemProps) => { const theme = useTheme(); const styles = getStyleSheet(theme); @@ -227,6 +229,7 @@ const OptionItem = ({ diff --git a/app/components/post_draft/post_draft.tsx b/app/components/post_draft/post_draft.tsx index c449a8057..e632c199f 100644 --- a/app/components/post_draft/post_draft.tsx +++ b/app/components/post_draft/post_draft.tsx @@ -4,15 +4,18 @@ import React, {RefObject, useEffect, useState} from 'react'; import {Platform, View} from 'react-native'; import {KeyboardTrackingView, KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import Autocomplete from '@components/autocomplete'; import {View as ViewConstants} from '@constants'; -import {useIsTablet} from '@hooks/device'; +import {useIsTablet, useKeyboardHeight} from '@hooks/device'; +import {useDefaultHeaderHeight} from '@hooks/header'; import Archived from './archived'; import DraftHandler from './draft_handler'; import ReadOnly from './read_only'; +const AUTOCOMPLETE_ADJUST = -5; type Props = { testID?: string; accessoriesContainerID?: string; @@ -27,6 +30,8 @@ type Props = { rootId?: string; scrollViewNativeID?: string; keyboardTracker: RefObject; + containerHeight: number; + isChannelScreen: boolean; } const {KEYBOARD_TRACKING_OFFSET} = ViewConstants; @@ -45,11 +50,16 @@ function PostDraft({ rootId = '', scrollViewNativeID, keyboardTracker, + containerHeight, + isChannelScreen, }: Props) { const [value, setValue] = useState(message); const [cursorPosition, setCursorPosition] = useState(message.length); const [postInputTop, setPostInputTop] = useState(0); const isTablet = useIsTablet(); + const keyboardHeight = useKeyboardHeight(keyboardTracker); + const insets = useSafeAreaInsets(); + const headerHeight = useDefaultHeaderHeight(); // Update draft in case we switch channels or threads useEffect(() => { @@ -92,9 +102,17 @@ function PostDraft({ /> ); + const keyboardAdjustment = (isTablet && isChannelScreen) ? KEYBOARD_TRACKING_OFFSET : 0; + const insetsAdjustment = (isTablet && isChannelScreen) ? 0 : insets.bottom; + const autocompletePosition = AUTOCOMPLETE_ADJUST + Platform.select({ + ios: (keyboardHeight ? keyboardHeight - keyboardAdjustment : (postInputTop + insetsAdjustment)), + default: postInputTop + insetsAdjustment, + }); + const autocompleteAvailableSpace = containerHeight - autocompletePosition - (isChannelScreen ? headerHeight + insets.top : 0); + const autoComplete = ( ); @@ -124,7 +144,7 @@ function PostDraft({ > {draftHandler} - + {autoComplete} diff --git a/app/constants/autocomplete.ts b/app/constants/autocomplete.ts index f940516f2..d3abc316f 100644 --- a/app/constants/autocomplete.ts +++ b/app/constants/autocomplete.ts @@ -17,11 +17,8 @@ export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g; export const CODE_REGEX = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g; -export const LIST_BOTTOM = -5; - -export const MAX_LIST_HEIGHT = 280; -export const MAX_LIST_DIFF = 50; -export const MAX_LIST_TABLET_DIFF = 140; +export const MAX_LIST_HEIGHT = 230; +export const MAX_LIST_TABLET_DIFF = 90; export default { ALL_SEARCH_FLAGS_REGEX, @@ -32,7 +29,5 @@ export default { CHANNEL_MENTION_SEARCH_REGEX, CODE_REGEX, DATE_MENTION_SEARCH_REGEX, - LIST_BOTTOM, MAX_LIST_HEIGHT, - MAX_LIST_DIFF, }; diff --git a/app/hooks/device.ts b/app/hooks/device.ts index 700719ddd..e6db62d43 100644 --- a/app/hooks/device.ts +++ b/app/hooks/device.ts @@ -1,8 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect, useState} from 'react'; -import {AppState, Keyboard, NativeModules, useWindowDimensions} from 'react-native'; +import React, {RefObject, useEffect, useRef, useState} from 'react'; +import {AppState, Keyboard, NativeModules, Platform, useWindowDimensions, View} from 'react-native'; +import {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {Device} from '@constants'; @@ -43,15 +45,44 @@ export function useIsTablet() { return Device.IS_TABLET && !isSplitView; } -export function useKeyboardHeight() { +export function useKeyboardHeight(keyboardTracker?: React.RefObject) { const [keyboardHeight, setKeyboardHeight] = useState(0); + const updateTimeout = useRef(null); + const insets = useSafeAreaInsets(); + + // This is a magic number. With tracking view, to properly get the final position, this had to be added. + const KEYBOARD_TRACKINGVIEW_SEPARATION = 4; + + const updateValue = (v: number) => { + if (updateTimeout.current != null) { + clearTimeout(updateTimeout.current); + updateTimeout.current = null; + } + updateTimeout.current = setTimeout(() => { + setKeyboardHeight(v); + updateTimeout.current = null; + }, 200); + }; useEffect(() => { - const show = Keyboard.addListener('keyboardWillShow', (event) => { - setKeyboardHeight(event.endCoordinates.height); + const show = Keyboard.addListener(Platform.select({ios: 'keyboardWillShow', default: 'keyboardDidShow'}), async (event) => { + if (keyboardTracker?.current) { + const props = await keyboardTracker.current.getNativeProps(); + if (props.keyboardHeight) { + updateValue((props.trackingViewHeight + props.keyboardHeight) - KEYBOARD_TRACKINGVIEW_SEPARATION); + } else { + updateValue((props.trackingViewHeight + insets.bottom) - KEYBOARD_TRACKINGVIEW_SEPARATION); + } + } else { + updateValue(event.endCoordinates.height); + } }); - const hide = Keyboard.addListener('keyboardWillHide', () => { + const hide = Keyboard.addListener(Platform.select({ios: 'keyboardWillHide', default: 'keyboardDidHide'}), () => { + if (updateTimeout.current != null) { + clearTimeout(updateTimeout.current); + updateTimeout.current = null; + } setKeyboardHeight(0); }); @@ -59,7 +90,24 @@ export function useKeyboardHeight() { show.remove(); hide.remove(); }; - }, []); + }, [keyboardTracker && insets.bottom]); return keyboardHeight; } + +export function useModalPosition(viewRef: RefObject, deps?: React.DependencyList) { + const [modalPosition, setModalPosition] = useState(0); + const isTablet = useIsTablet(); + + useEffect(() => { + if (Platform.OS === 'ios' && isTablet) { + viewRef.current?.measureInWindow((_, y) => { + if (y !== modalPosition) { + setModalPosition(y); + } + }); + } + }, deps); + + return modalPosition; +} diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index c5556bed0..354fbfbdb 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useRef, useState} from 'react'; -import {BackHandler, DeviceEventEmitter, NativeEventSubscription, StyleSheet, View} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {BackHandler, DeviceEventEmitter, LayoutChangeEvent, NativeEventSubscription, StyleSheet, View} from 'react-native'; import {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view'; import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; @@ -61,6 +61,7 @@ const Channel = ({ const switchingChannels = useChannelSwitch(); const defaultHeight = useDefaultHeaderHeight(); const postDraftRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(0); const shouldRender = !switchingTeam && !switchingChannels && shouldRenderPosts && Boolean(channelId); @@ -113,6 +114,10 @@ const Channel = ({ }; }, [channelId]); + const onLayout = useCallback((e: LayoutChangeEvent) => { + setContainerHeight(e.nativeEvent.layout.height); + }, []); + let callsComponents: JSX.Element | null = null; const showJoinCallBanner = isCallInCurrentChannel && !isInCurrentChannelCall; if (isCallsPluginEnabled && (showJoinCallBanner || isInACall)) { @@ -136,6 +141,7 @@ const Channel = ({ mode='margin' edges={edges} testID='channel.screen' + onLayout={onLayout} > } diff --git a/app/screens/create_or_edit_channel/channel_info_form.tsx b/app/screens/create_or_edit_channel/channel_info_form.tsx index dde06cedf..64dd35c6f 100644 --- a/app/screens/create_or_edit_channel/channel_info_form.tsx +++ b/app/screens/create_or_edit_channel/channel_info_form.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState, useRef, useCallback} from 'react'; +import React, {useState, useRef, useCallback, useEffect} from 'react'; import {useIntl} from 'react-intl'; import { LayoutChangeEvent, @@ -12,10 +12,10 @@ import { NativeSyntheticEvent, NativeScrollEvent, Platform, - KeyboardEvent, + useWindowDimensions, } from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; -import {SafeAreaView} from 'react-native-safe-area-context'; +import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import Autocomplete from '@components/autocomplete'; import ErrorText from '@components/error_text'; @@ -25,8 +25,7 @@ import Loading from '@components/loading'; import OptionItem from '@components/option_item'; import {General, Channel} from '@constants'; import {useTheme} from '@context/theme'; -import {useIsTablet} from '@hooks/device'; -import useHeaderHeight from '@hooks/header'; +import {useIsTablet, useKeyboardHeight, useModalPosition} from '@hooks/device'; import {t} from '@i18n'; import { changeOpacity, @@ -35,12 +34,18 @@ import { } from '@utils/theme'; import {typography} from '@utils/typography'; +const FIELD_MARGIN_BOTTOM = 24; +const MAKE_PRIVATE_MARGIN_BOTTOM = 32; +const BOTTOM_AUTOCOMPLETE_SEPARATION = Platform.select({ios: 10, default: 10}); +const LIST_PADDING = 32; +const AUTOCOMPLETE_ADJUST = 5; + const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ container: { flex: 1, }, scrollView: { - paddingVertical: 32, + paddingVertical: LIST_PADDING, paddingHorizontal: 20, }, errorContainer: { @@ -56,10 +61,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ justifyContent: 'center', }, makePrivateContainer: { - marginBottom: 32, + marginBottom: MAKE_PRIVATE_MARGIN_BOTTOM, }, fieldContainer: { - marginBottom: 24, + marginBottom: FIELD_MARGIN_BOTTOM, }, helpText: { ...typography('Body', 75, 'Regular'), @@ -101,8 +106,7 @@ export default function ChannelInfoForm({ }: Props) { const intl = useIntl(); const {formatMessage} = intl; - const isTablet = useIsTablet(); - const headerHeight = useHeaderHeight(); + const insets = useSafeAreaInsets(); const theme = useTheme(); const styles = getStyleSheet(theme); @@ -115,10 +119,22 @@ export default function ChannelInfoForm({ const updateScrollTimeout = useRef(); + const mainView = useRef(null); + const modalPosition = useModalPosition(mainView); + + const dimensions = useWindowDimensions(); + const isTablet = useIsTablet(); + + const keyboardHeight = useKeyboardHeight(); const [keyboardVisible, setKeyBoardVisible] = useState(false); - const [keyboardHeight, setKeyboardHeight] = useState(0); const [scrollPosition, setScrollPosition] = useState(0); + const [wrapperHeight, setWrapperHeight] = useState(0); + const [errorHeight, setErrorHeight] = useState(0); + const [displayNameFieldHeight, setDisplayNameFieldHeight] = useState(0); + const [makePrivateHeight, setMakePrivateHeight] = useState(0); + const [purposeFieldHeight, setPurposeFieldHeight] = useState(0); + const [headerFieldHeight, setHeaderFieldHeight] = useState(0); const [headerPosition, setHeaderPosition] = useState(0); const optionalText = formatMessage({id: t('channel_modal.optional'), defaultMessage: '(optional)'}); @@ -150,28 +166,12 @@ export default function ChannelInfoForm({ scrollViewRef.current?.scrollToPosition(0, 0, true); }, []); - const onHeaderLayout = useCallback(({nativeEvent}: LayoutChangeEvent) => { - setHeaderPosition(nativeEvent.layout.y); - }, []); - const scrollHeaderToTop = useCallback(() => { if (scrollViewRef?.current) { scrollViewRef.current?.scrollToPosition(0, headerPosition); } }, [headerPosition]); - const onKeyboardDidShow = useCallback((frames: KeyboardEvent) => { - setKeyBoardVisible(true); - if (Platform.OS === 'android') { - setKeyboardHeight(frames.endCoordinates.height); - } - }, []); - - const onKeyboardDidHide = useCallback(() => { - setKeyBoardVisible(false); - setKeyboardHeight(0); - }, []); - const onScroll = useCallback((e: NativeSyntheticEvent) => { const pos = e.nativeEvent.contentOffset.y; if (updateScrollTimeout.current) { @@ -183,6 +183,35 @@ export default function ChannelInfoForm({ }, 200); }, []); + useEffect(() => { + if (keyboardVisible && !keyboardHeight) { + setKeyBoardVisible(false); + } + if (!keyboardVisible && keyboardHeight) { + setKeyBoardVisible(true); + } + }, [keyboardHeight]); + + const onLayoutError = useCallback((e: LayoutChangeEvent) => { + setErrorHeight(e.nativeEvent.layout.height); + }, []); + const onLayoutMakePrivate = useCallback((e: LayoutChangeEvent) => { + setMakePrivateHeight(e.nativeEvent.layout.height); + }, []); + const onLayoutDisplayName = useCallback((e: LayoutChangeEvent) => { + setDisplayNameFieldHeight(e.nativeEvent.layout.height); + }, []); + const onLayoutPurpose = useCallback((e: LayoutChangeEvent) => { + setPurposeFieldHeight(e.nativeEvent.layout.height); + }, []); + const onLayoutHeader = useCallback((e: LayoutChangeEvent) => { + setHeaderFieldHeight(e.nativeEvent.layout.height); + setHeaderPosition(e.nativeEvent.layout.y); + }, []); + const onLayoutWrapper = useCallback((e: LayoutChangeEvent) => { + setWrapperHeight(e.nativeEvent.layout.height); + }, []); + if (saving) { return ( @@ -202,6 +231,7 @@ export default function ChannelInfoForm({ spaceOnBottom ? (workingSpace + scrollPosition + AUTOCOMPLETE_ADJUST + keyboardAdjust) - otherElementsSize : (workingSpace + scrollPosition + keyboardAdjust) - (otherElementsSize + headerFieldHeight); + const autocompleteAvailableSpace = spaceOnTop > spaceOnBottom ? spaceOnTop : spaceOnBottom; + const growUp = spaceOnTop > spaceOnBottom; return ( )} {!displayHeaderOnly && ( @@ -270,8 +322,12 @@ export default function ChannelInfoForm({ ref={nameInput} containerStyle={styles.fieldContainer} theme={theme} + onLayout={onLayoutDisplayName} /> - + diff --git a/app/screens/edit_post/edit_post.tsx b/app/screens/edit_post/edit_post.tsx index 6b65da634..a8efe5bc9 100644 --- a/app/screens/edit_post/edit_post.tsx +++ b/app/screens/edit_post/edit_post.tsx @@ -4,14 +4,14 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Alert, Keyboard, KeyboardType, LayoutChangeEvent, Platform, SafeAreaView, useWindowDimensions, View} from 'react-native'; -import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {deletePost, editPost} from '@actions/remote/post'; import Autocomplete from '@components/autocomplete'; import Loading from '@components/loading'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {useIsTablet} from '@hooks/device'; +import {useIsTablet, useKeyboardHeight, useModalPosition} from '@hooks/device'; import useDidUpdate from '@hooks/did_update'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import PostError from '@screens/edit_post/post_error'; @@ -23,6 +23,8 @@ import EditPostInput, {EditPostInputRef} from './edit_post_input'; import type PostModel from '@typings/database/models/servers/post'; +const AUTOCOMPLETE_SEPARATION = 8; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { body: { @@ -57,17 +59,20 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach const [errorLine, setErrorLine] = useState(); const [errorExtra, setErrorExtra] = useState(); const [isUpdating, setIsUpdating] = useState(false); - const layoutHeight = useSharedValue(0); - const keyboardHeight = useSharedValue(0); + const [containerHeight, setContainerHeight] = useState(0); + + const mainView = useRef(null); + const modalPosition = useModalPosition(mainView); const postInputRef = useRef(null); const theme = useTheme(); const intl = useIntl(); const serverUrl = useServerUrl(); - const isTablet = useIsTablet(); - const {width, height} = useWindowDimensions(); - const isLandscape = width > height; const styles = getStyleSheet(theme); + const keyboardHeight = useKeyboardHeight(); + const insets = useSafeAreaInsets(); + const dimensions = useWindowDimensions(); + const isTablet = useIsTablet(); useEffect(() => { setButtons(componentId, { @@ -80,31 +85,6 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach }); }, []); - useEffect(() => { - const showListener = Keyboard.addListener('keyboardWillShow', (e) => { - const {height: end} = e.endCoordinates; - - // on iPad if we use the hardware keyboard multiply its height by 2 - // otherwise use the software keyboard height - const minKeyboardHeight = end < 100 ? end * 2 : end; - keyboardHeight.value = minKeyboardHeight; - }); - const hideListener = Keyboard.addListener('keyboardWillHide', () => { - if (isTablet) { - const offset = isLandscape ? 60 : 0; - keyboardHeight.value = ((height - (layoutHeight.value + offset)) / 2); - return; - } - - keyboardHeight.value = 0; - }); - - return () => { - showListener.remove(); - hideListener.remove(); - }; - }, [isTablet, height]); - useEffect(() => { const t = setTimeout(() => { postInputRef.current?.focus(); @@ -127,10 +107,6 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach dismissModal({componentId}); }, []); - const onLayout = useCallback((e: LayoutChangeEvent) => { - layoutHeight.value = e.nativeEvent.layout.height; - }, [height]); - const onTextSelectionChange = useCallback((curPos: number = cursorPosition) => { if (Platform.OS === 'ios') { setKeyboardType(switchKeyboardForCodeBlocks(postMessage, curPos)); @@ -214,25 +190,9 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach handleUIUpdates(res); }, [toggleSaveButton, serverUrl, post.id, postMessage, onClose]); - const animatedStyle = useAnimatedStyle(() => { - if (Platform.OS === 'android') { - return {bottom: 0}; - } - - let bottom = 0; - if (isTablet) { - // 60 is the size of the navigation header - const offset = isLandscape ? 60 : 0; - - bottom = keyboardHeight.value - ((height - (layoutHeight.value + offset)) / 2); - } else { - bottom = keyboardHeight.value; - } - - return { - bottom: withTiming(bottom, {duration: 250}), - }; - }); + const onLayout = useCallback((e: LayoutChangeEvent) => { + setContainerHeight(e.nativeEvent.layout.height); + }, []); useNavButtonPressed(RIGHT_BUTTON.id, componentId, onSavePostMessage, [postMessage]); useNavButtonPressed(closeButtonId, componentId, onClose, []); @@ -245,6 +205,14 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach ); } + const bottomSpace = (dimensions.height - containerHeight - modalPosition); + const autocompletePosition = Platform.select({ + ios: isTablet ? + Math.max(0, keyboardHeight - bottomSpace) : + keyboardHeight || insets.bottom, + default: 0}) + AUTOCOMPLETE_SEPARATION; + const autocompleteAvailableSpace = containerHeight - autocompletePosition; + return ( <> - + {Boolean((errorLine || errorExtra)) && - - - - + ); }; diff --git a/app/screens/home/search/search.tsx b/app/screens/home/search/search.tsx index b87bcd7a1..92539d239 100644 --- a/app/screens/home/search/search.tsx +++ b/app/screens/home/search/search.tsx @@ -4,9 +4,9 @@ import {useIsFocused, useNavigation} from '@react-navigation/native'; import React, {useCallback, useMemo, useState} from 'react'; import {useIntl} from 'react-intl'; -import {FlatList, StyleSheet} from 'react-native'; +import {FlatList, LayoutChangeEvent, Platform, StyleSheet} from 'react-native'; import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; -import {Edge, SafeAreaView} from 'react-native-safe-area-context'; +import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import {addSearchToTeamSearchHistory} from '@actions/local/team'; import {searchPosts, searchFiles} from '@actions/remote/search'; @@ -15,8 +15,10 @@ import FreezeScreen from '@components/freeze_screen'; import Loading from '@components/loading'; import NavigationHeader from '@components/navigation_header'; import RoundedHeaderContext from '@components/rounded_header_context'; +import {BOTTOM_TAB_HEIGHT} from '@constants/view'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import {useKeyboardHeight} from '@hooks/device'; import {useCollapsibleHeader} from '@hooks/header'; import {FileFilter, FileFilters, filterFileExtensions} from '@utils/file'; import {TabTypes, TabType} from '@utils/search'; @@ -34,7 +36,7 @@ const emptyChannelIds: string[] = []; const dummyData = [1]; -const AutocompletePaddingTop = -4; +const AutocompletePaddingTop = 4; const AutocompleteZindex = 11; type Props = { @@ -68,6 +70,8 @@ const SearchScreen = ({teamId}: Props) => { const isFocused = useIsFocused(); const intl = useIntl(); const theme = useTheme(); + const insets = useSafeAreaInsets(); + const keyboardHeight = useKeyboardHeight(); const stateIndex = nav.getState().index; const serverUrl = useServerUrl(); @@ -79,6 +83,7 @@ const SearchScreen = ({teamId}: Props) => { const [selectedTab, setSelectedTab] = useState(TabTypes.MESSAGES); const [filter, setFilter] = useState(FileFilters.ALL); const [showResults, setShowResults] = useState(false); + const [containerHeight, setContainerHeight] = useState(0); const [loading, setLoading] = useState(false); const [lastSearchedValue, setLastSearchedValue] = useState(''); @@ -218,7 +223,11 @@ const SearchScreen = ({teamId}: Props) => { top: headerHeight.value, zIndex: lastSearchedValue ? 10 : 0, }; - }, [headerHeight, lastSearchedValue]); + }, [headerHeight.value, lastSearchedValue]); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + setContainerHeight(e.nativeEvent.layout.height); + }, []); let header = null; if (lastSearchedValue && !loading) { @@ -235,17 +244,25 @@ const SearchScreen = ({teamId}: Props) => { /> ); } + + const autocompleteRemoveFromHeight = headerHeight.value + Platform.select({ + ios: keyboardHeight ? keyboardHeight - BOTTOM_TAB_HEIGHT : insets.bottom, + default: 0, + }); + const autocompleteMaxHeight = containerHeight - autocompleteRemoveFromHeight; + const autocompletePosition = AutocompletePaddingTop; const autocomplete = useMemo(() => ( - ), [cursorPosition, handleTextChange, searchValue]); + ), [cursorPosition, handleTextChange, searchValue, autocompleteMaxHeight, autocompletePosition]); return ( @@ -270,6 +287,7 @@ const SearchScreen = ({teamId}: Props) => { diff --git a/app/screens/thread/thread.tsx b/app/screens/thread/thread.tsx index e65e53e20..86ce5d183 100644 --- a/app/screens/thread/thread.tsx +++ b/app/screens/thread/thread.tsx @@ -1,14 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useRef} from 'react'; -import {StyleSheet, View} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {DeviceEventEmitter, LayoutChangeEvent, StyleSheet, View} from 'react-native'; import {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view'; import {Edge, SafeAreaView} from 'react-native-safe-area-context'; import FreezeScreen from '@components/freeze_screen'; import PostDraft from '@components/post_draft'; import RoundedHeaderContext from '@components/rounded_header_context'; +import {Events} from '@constants'; import {THREAD_ACCESSORIES_CONTAINER_NATIVE_ID} from '@constants/post_draft'; import {useAppState} from '@hooks/device'; import useDidUpdate from '@hooks/did_update'; @@ -33,6 +34,20 @@ const styles = StyleSheet.create({ const Thread = ({componentId, rootPost}: ThreadProps) => { const appState = useAppState(); const postDraftRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(0); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.PAUSE_KEYBOARD_TRACKING_VIEW, (pause: boolean) => { + if (pause) { + postDraftRef.current?.pauseTracking(rootPost!.id); + return; + } + + postDraftRef.current?.resumeTracking(rootPost!.id); + }); + + return () => listener.remove(); + }, []); useEffect(() => { return () => { @@ -46,6 +61,10 @@ const Thread = ({componentId, rootPost}: ThreadProps) => { } }, [componentId, rootPost]); + const onLayout = useCallback((e: LayoutChangeEvent) => { + setContainerHeight(e.nativeEvent.layout.height); + }, []); + return ( { mode='margin' edges={edges} testID='thread.screen' + onLayout={onLayout} > {Boolean(rootPost?.id) && @@ -71,6 +91,8 @@ const Thread = ({componentId, rootPost}: ThreadProps) => { rootId={rootPost!.id} keyboardTracker={postDraftRef} testID='thread.post_draft' + containerHeight={containerHeight} + isChannelScreen={false} /> } diff --git a/types/modules/react-native-keyboard-tracking-view.d.ts b/types/modules/react-native-keyboard-tracking-view.d.ts index a768a122a..cd1df1c41 100644 --- a/types/modules/react-native-keyboard-tracking-view.d.ts +++ b/types/modules/react-native-keyboard-tracking-view.d.ts @@ -8,6 +8,13 @@ declare module 'react-native-keyboard-tracking-view' { resumeTracking: (id: string) => void; resetScrollView: (id: string) => void; setNativeProps(nativeProps: object): void; + getNativeProps: () => Promise; + } + + type KeyboardTrackingViewNativeProps = { + contentTopInset: number; + keyboardHeight: number; + trackingViewHeight: number; } interface KeyboardTrackingViewProps extends ViewProps{