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 <danielespino@MacBook-Pro-de-Daniel.local>
This commit is contained in:
Daniel Espino García 2022-08-13 14:34:26 +02:00 committed by GitHub
parent 6e3b2c2bd9
commit afd818996e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 303 additions and 175 deletions

View file

@ -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 (
<View

View file

@ -75,7 +75,7 @@ type Props = {
nestedScrollEnabled: boolean;
skinTone: string;
hasFilesAttached?: boolean;
inPost?: boolean;
inPost: boolean;
}
const EmojiSuggestion = ({
cursorPosition,
@ -88,7 +88,7 @@ const EmojiSuggestion = ({
nestedScrollEnabled,
skinTone,
hasFilesAttached = false,
inPost = true,
inPost,
}: Props) => {
const insets = useSafeAreaInsets();
const theme = useTheme();

View file

@ -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 = ({
<View
testID={testID}
style={[styles.container, containerStyle]}
onLayout={onLayout}
>
<View style={styles.row}>
<View style={styles.labelContainer}>

View file

@ -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<KeyboardTrackingViewRef>;
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 = (
<Autocomplete
postInputTop={postInputTop}
position={autocompletePosition}
updateValue={setValue}
rootId={rootId}
channelId={channelId}
@ -102,6 +120,8 @@ function PostDraft({
value={value}
isSearch={isSearch}
hasFilesAttached={Boolean(files?.length)}
inPost={true}
availableSpace={autocompleteAvailableSpace}
/>
);
@ -124,7 +144,7 @@ function PostDraft({
>
{draftHandler}
</KeyboardTrackingView>
<View nativeID={accessoriesContainerID}>
<View>
{autoComplete}
</View>
</>

View file

@ -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,
};

View file

@ -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<KeyboardTrackingViewRef>) {
const [keyboardHeight, setKeyboardHeight] = useState(0);
const updateTimeout = useRef<NodeJS.Timeout | null>(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<View>, 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;
}

View file

@ -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<KeyboardTrackingViewRef>(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}
>
<ChannelHeader
channelId={channelId}
@ -159,6 +165,8 @@ const Channel = ({
scrollViewNativeID={channelId}
accessoriesContainerID={ACCESSORIES_CONTAINER_NATIVE_ID}
testID='channel.post_draft'
containerHeight={containerHeight}
isChannelScreen={true}
/>
</>
}

View file

@ -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<NodeJS.Timeout>();
const mainView = useRef<View>(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<NativeScrollEvent>) => {
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 (
<View style={styles.container}>
@ -202,6 +231,7 @@ export default function ChannelInfoForm({
<SafeAreaView
edges={['bottom', 'left', 'right']}
style={styles.errorContainer}
onLayout={onLayoutError}
>
<View style={styles.errorWrapper}>
<ErrorText
@ -213,21 +243,42 @@ export default function ChannelInfoForm({
);
}
const platformHeaderHeight = headerHeight.defaultHeight + Platform.select({ios: 10, default: headerHeight.defaultHeight + 10});
const postInputTop = (headerPosition + scrollPosition + platformHeaderHeight) - keyboardHeight;
const bottomSpace = (dimensions.height - wrapperHeight - modalPosition);
const otherElementsSize = LIST_PADDING + errorHeight +
(showSelector ? makePrivateHeight + MAKE_PRIVATE_MARGIN_BOTTOM : 0) +
(displayHeaderOnly ? 0 : purposeFieldHeight + FIELD_MARGIN_BOTTOM + displayNameFieldHeight + FIELD_MARGIN_BOTTOM);
const keyboardOverlap = Platform.select({
ios: isTablet ?
Math.max(0, keyboardHeight - bottomSpace) :
keyboardHeight || insets.bottom,
default: 0});
const workingSpace = wrapperHeight - keyboardOverlap;
const spaceOnTop = otherElementsSize - scrollPosition - AUTOCOMPLETE_ADJUST;
const spaceOnBottom = (workingSpace + scrollPosition) - (otherElementsSize + headerFieldHeight + BOTTOM_AUTOCOMPLETE_SEPARATION);
const insetsAdjust = keyboardHeight - keyboardHeight ? insets.bottom : 0;
const keyboardAdjust = Platform.select({
ios: isTablet ?
keyboardOverlap :
insetsAdjust,
default: 0,
});
const autocompletePosition = spaceOnTop > spaceOnBottom ? (workingSpace + scrollPosition + AUTOCOMPLETE_ADJUST + keyboardAdjust) - otherElementsSize : (workingSpace + scrollPosition + keyboardAdjust) - (otherElementsSize + headerFieldHeight);
const autocompleteAvailableSpace = spaceOnTop > spaceOnBottom ? spaceOnTop : spaceOnBottom;
const growUp = spaceOnTop > spaceOnBottom;
return (
<SafeAreaView
edges={['bottom', 'left', 'right']}
style={styles.container}
testID='create_or_edit_channel.screen'
onLayout={onLayoutWrapper}
ref={mainView}
>
<KeyboardAwareScrollView
testID={'create_or_edit_channel.scrollview'}
ref={scrollViewRef}
keyboardShouldPersistTaps={'always'}
onKeyboardDidShow={onKeyboardDidShow}
onKeyboardDidHide={onKeyboardDidHide}
enableAutomaticScroll={!keyboardVisible}
contentContainerStyle={styles.scrollView}
onScroll={onScroll}
@ -247,6 +298,7 @@ export default function ChannelInfoForm({
selected={isPrivate}
icon={'lock-outline'}
containerStyle={styles.makePrivateContainer}
onLayout={onLayoutMakePrivate}
/>
)}
{!displayHeaderOnly && (
@ -270,8 +322,12 @@ export default function ChannelInfoForm({
ref={nameInput}
containerStyle={styles.fieldContainer}
theme={theme}
onLayout={onLayoutDisplayName}
/>
<View style={styles.fieldContainer}>
<View
style={styles.fieldContainer}
onLayout={onLayoutPurpose}
>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
@ -301,7 +357,6 @@ export default function ChannelInfoForm({
)}
<View
style={styles.fieldContainer}
onLayout={onHeaderLayout}
>
<FloatingTextInput
autoCorrect={false}
@ -322,6 +377,7 @@ export default function ChannelInfoForm({
ref={headerInput}
theme={theme}
onFocus={scrollHeaderToTop}
onLayout={onLayoutHeader}
/>
<FormattedText
style={styles.helpText}
@ -336,13 +392,14 @@ export default function ChannelInfoForm({
</KeyboardAwareScrollView>
<View>
<Autocomplete
postInputTop={postInputTop}
position={autocompletePosition}
updateValue={onHeaderChange}
cursorPosition={header.length}
value={header}
nestedScrollEnabled={true}
maxHeightOverride={isTablet ? 200 : undefined}
availableSpace={autocompleteAvailableSpace}
inPost={false}
growDown={!growUp}
/>
</View>
</SafeAreaView>

View file

@ -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<string | undefined>();
const [errorExtra, setErrorExtra] = useState<string | undefined>();
const [isUpdating, setIsUpdating] = useState(false);
const layoutHeight = useSharedValue(0);
const keyboardHeight = useSharedValue(0);
const [containerHeight, setContainerHeight] = useState(0);
const mainView = useRef<View>(null);
const modalPosition = useModalPosition(mainView);
const postInputRef = useRef<EditPostInputRef>(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 (
<>
<SafeAreaView
@ -252,7 +220,10 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
style={styles.container}
onLayout={onLayout}
>
<View style={styles.body}>
<View
style={styles.body}
ref={mainView}
>
{Boolean((errorLine || errorExtra)) &&
<PostError
errorExtra={errorExtra}
@ -269,22 +240,18 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
/>
</View>
</SafeAreaView>
<Animated.View style={animatedStyle}>
<Autocomplete
channelId={post.channelId}
hasFilesAttached={hasFilesAttached}
nestedScrollEnabled={true}
rootId={post.rootId}
updateValue={onChangeText}
value={postMessage}
cursorPosition={cursorPosition}
postInputTop={1}
fixedBottomPosition={true}
maxHeightOverride={isTablet ? 200 : undefined}
inPost={false}
/>
</Animated.View>
<Autocomplete
channelId={post.channelId}
hasFilesAttached={hasFilesAttached}
nestedScrollEnabled={true}
rootId={post.rootId}
updateValue={onChangeText}
value={postMessage}
cursorPosition={cursorPosition}
position={autocompletePosition}
availableSpace={autocompleteAvailableSpace}
inPost={false}
/>
</>
);
};

View file

@ -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<TabType>(TabTypes.MESSAGES);
const [filter, setFilter] = useState<FileFilter>(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(() => (
<Autocomplete
paddingTop={AutocompletePaddingTop}
postInputTop={0}
updateValue={handleTextChange}
cursorPosition={cursorPosition}
value={searchValue}
isSearch={true}
hasFilesAttached={false}
availableSpace={autocompleteMaxHeight}
position={autocompletePosition}
growDown={true}
/>
), [cursorPosition, handleTextChange, searchValue]);
), [cursorPosition, handleTextChange, searchValue, autocompleteMaxHeight, autocompletePosition]);
return (
<FreezeScreen freeze={!isFocused}>
@ -270,6 +287,7 @@ const SearchScreen = ({teamId}: Props) => {
<SafeAreaView
style={styles.flex}
edges={EDGES}
onLayout={onLayout}
>
<Animated.View style={animated}>
<Animated.View style={top}>

View file

@ -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<KeyboardTrackingViewRef>(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 (
<FreezeScreen>
<SafeAreaView
@ -53,6 +72,7 @@ const Thread = ({componentId, rootPost}: ThreadProps) => {
mode='margin'
edges={edges}
testID='thread.screen'
onLayout={onLayout}
>
<RoundedHeaderContext/>
{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}
/>
</>
}

View file

@ -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<KeyboardTrackingViewNativeProps>;
}
type KeyboardTrackingViewNativeProps = {
contentTopInset: number;
keyboardHeight: number;
trackingViewHeight: number;
}
interface KeyboardTrackingViewProps extends ViewProps{