diff --git a/app/components/badge/index.tsx b/app/components/badge/index.tsx index abf4ef5b0a..c9943aecde 100644 --- a/app/components/badge/index.tsx +++ b/app/components/badge/index.tsx @@ -8,7 +8,7 @@ import {useTheme} from '@context/theme'; type Props = { backgroundColor?: string; - borderColor: string; + borderColor?: string; color?: string; style?: Animated.WithAnimatedValue>; type?: 'Normal' | 'Small'; diff --git a/app/components/navigation_header/context.tsx b/app/components/navigation_header/context.tsx new file mode 100644 index 0000000000..2ce076573f --- /dev/null +++ b/app/components/navigation_header/context.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; + +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + defaultHeight: number; + hasSearch: boolean; + isLargeTitle: boolean; + largeHeight: number; + scrollValue: Animated.SharedValue; + theme: Theme; + top: number; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: theme.sidebarBg, + height: 16, + position: 'absolute', + width: '100%', + }, + content: { + backgroundColor: theme.centerChannelBg, + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + flex: 1, + }, +})); + +const NavigationHeaderContext = ({ + defaultHeight, + hasSearch, + isLargeTitle, + largeHeight, + scrollValue, + theme, + top, +}: Props) => { + const styles = getStyleSheet(theme); + + const marginTop = useAnimatedStyle(() => { + const normal = defaultHeight + top; + const calculated = -(top + scrollValue.value); + const searchHeight = hasSearch ? defaultHeight + 9 : 0; + if (!isLargeTitle) { + return {marginTop: Math.max((normal + calculated), normal)}; + } + + return {marginTop: Math.max((-scrollValue.value + largeHeight + searchHeight), normal)}; + }, [defaultHeight, largeHeight, isLargeTitle, hasSearch, top]); + + return ( + + + + ); +}; + +export default NavigationHeaderContext; + diff --git a/app/components/navigation_header/header.tsx b/app/components/navigation_header/header.tsx new file mode 100644 index 0000000000..6e35b58ec2 --- /dev/null +++ b/app/components/navigation_header/header.tsx @@ -0,0 +1,194 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {Platform, Text} from 'react-native'; +import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +export type HeaderRightButton = { + borderless?: boolean; + buttonType?: 'native' | 'opacity' | 'highlight'; + color?: string; + iconName: string; + onPress: () => void; + rippleRadius?: number; + testID?: string; +} + +type Props = { + defaultHeight: number; + hasSearch: boolean; + isLargeTitle: boolean; + largeHeight: number; + leftComponent?: React.ReactElement; + onBackPress?: () => void; + rightButtons?: HeaderRightButton[]; + scrollValue: Animated.SharedValue; + showBackButton?: boolean; + subtitle?: string; + theme: Theme; + title?: string; + top: number; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + alignItems: 'center', + backgroundColor: theme.sidebarBg, + flexDirection: 'row', + justifyContent: 'flex-start', + paddingHorizontal: 16, + zIndex: 10, + }, + subtitle: { + color: changeOpacity(theme.sidebarHeaderTextColor, 0.72), + fontFamily: 'OpenSans', + fontSize: 12, + lineHeight: 12, + marginBottom: 8, + marginTop: 2, + }, + titleContainer: { + alignItems: Platform.select({android: 'flex-start', ios: 'center'}), + justifyContent: 'center', + flex: 3, + height: '100%', + paddingHorizontal: 8, + }, + leftContainer: { + alignItems: 'center', + flex: Platform.select({ios: 1}), + flexDirection: 'row', + height: '100%', + }, + rightContainer: { + alignItems: 'center', + flex: Platform.select({ios: 1}), + flexDirection: 'row', + height: '100%', + justifyContent: 'flex-end', + }, + rightIcon: { + marginLeft: 20, + }, + title: { + color: theme.sidebarHeaderTextColor, + ...typography('Heading', 300), + }, +})); + +const Header = ({ + defaultHeight, + hasSearch, + isLargeTitle, + largeHeight, + leftComponent, + onBackPress, + rightButtons, + scrollValue, + showBackButton = true, + subtitle, + theme, + title, + top, +}: Props) => { + const styles = getStyleSheet(theme); + + const opacity = useAnimatedStyle(() => { + if (!isLargeTitle) { + return {opacity: 1}; + } + + if (hasSearch) { + return {opacity: 0}; + } + + const barHeight = Platform.OS === 'ios' ? (largeHeight - defaultHeight - (top / 2)) : largeHeight - defaultHeight; + const val = (top + scrollValue.value); + return { + opacity: val >= barHeight ? withTiming(1, {duration: 250}) : 0, + }; + }, [defaultHeight, largeHeight, top, isLargeTitle, hasSearch]); + + const containerStyle = useMemo(() => { + return [styles.container, {height: defaultHeight + top, paddingTop: top}]; + }, [top, defaultHeight, theme]); + + const additionalTitleStyle = useMemo(() => ({ + marginLeft: Platform.select({android: showBackButton && !leftComponent ? 20 : 0}), + }), [leftComponent, showBackButton, theme]); + + return ( + + + {showBackButton && + + + + } + {leftComponent} + + + {!hasSearch && + + {title} + + } + {!isLargeTitle && + + {subtitle} + + } + + + {Boolean(rightButtons?.length) && + rightButtons?.map((r, i) => ( + 0 ? styles.rightIcon : undefined} + testID={r.testID} + > + + + )) + } + + + ); +}; + +export default Header; + diff --git a/app/components/navigation_header/index.tsx b/app/components/navigation_header/index.tsx new file mode 100644 index 0000000000..6518444592 --- /dev/null +++ b/app/components/navigation_header/index.tsx @@ -0,0 +1,135 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FlatList, ScrollView, SectionList} from 'react-native'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {useTheme} from '@context/theme'; +import useHeaderHeight from '@hooks/header'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import NavigationHeaderContext from './context'; +import Header, {HeaderRightButton} from './header'; +import NavigationHeaderLargeTitle from './large'; +import NavigationSearch from './search'; +import NavigationHeaderSearchContext from './search_context'; + +import type {SearchProps} from '@components/search'; + +type Props = SearchProps & { + forwardedRef?: React.RefObject; + hasSearch?: boolean; + isLargeTitle?: boolean; + leftComponent?: React.ReactElement; + onBackPress?: () => void; + rightButtons?: HeaderRightButton[]; + scrollValue: Animated.SharedValue; + showBackButton?: boolean; + showHeaderInContext?: boolean; + subtitle?: string; + title?: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: theme.sidebarBg, + position: 'absolute', + width: '100%', + zIndex: 10, + }, +})); + +const NavigationHeader = ({ + forwardedRef, + hasSearch = false, + isLargeTitle = false, + leftComponent, + onBackPress, + rightButtons, + scrollValue, + showBackButton, + showHeaderInContext = true, + subtitle, + title = '', + ...searchProps +}: Props) => { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const styles = getStyleSheet(theme); + + const {largeHeight, defaultHeight} = useHeaderHeight(isLargeTitle, Boolean(subtitle), hasSearch); + const containerHeight = useAnimatedStyle(() => { + const normal = defaultHeight + insets.top; + const calculated = -(insets.top + scrollValue.value); + return {height: Math.max((normal + calculated), normal)}; + }, [defaultHeight, insets.top]); + + return ( + <> + +
+ {isLargeTitle && + + } + + {hasSearch && + <> + + + + } + {showHeaderInContext && + + } + + ); +}; + +export default NavigationHeader; + diff --git a/app/components/navigation_header/large.tsx b/app/components/navigation_header/large.tsx new file mode 100644 index 0000000000..93a1eb570b --- /dev/null +++ b/app/components/navigation_header/large.tsx @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {Text} from 'react-native'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; + +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + defaultHeight: number; + hasSearch: boolean; + largeHeight: number; + scrollValue: Animated.SharedValue; + subtitle?: string; + theme: Theme; + title: string; + top: number; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: theme.sidebarBg, + paddingHorizontal: 20, + }, + heading: { + ...typography('Heading', 800), + color: theme.sidebarHeaderTextColor, + }, + subHeading: { + ...typography('Heading', 200, 'Regular'), + color: changeOpacity(theme.sidebarHeaderTextColor, 0.8), + }, +})); + +const NavigationHeaderLargeTitle = ({ + defaultHeight, + largeHeight, + hasSearch, + scrollValue, + subtitle, + theme, + title, + top, +}: Props) => { + const styles = getStyleSheet(theme); + + const transform = useAnimatedStyle(() => { + return { + transform: [{translateY: -(top + scrollValue.value)}], + }; + }, [top]); + + const containerStyle = useMemo(() => { + return [{height: largeHeight - defaultHeight}, styles.container]; + }, [defaultHeight, largeHeight, theme]); + + return ( + + + {title} + + {!hasSearch && Boolean(subtitle) && + + {subtitle} + + } + + ); +}; + +export default NavigationHeaderLargeTitle; + diff --git a/app/components/navigation_header/search.tsx b/app/components/navigation_header/search.tsx new file mode 100644 index 0000000000..9e2371ffbb --- /dev/null +++ b/app/components/navigation_header/search.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {FlatList, Platform, ScrollView, SectionList, VirtualizedList} from 'react-native'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; + +import Search, {SearchProps} from '@components/search'; +import {ANDROID_HEADER_SEARCH_INSET, IOS_HEADER_SEARCH_INSET, SEARCH_INPUT_HEIGHT, TABLET_HEADER_SEARCH_INSET} from '@constants/view'; +import {useIsTablet} from '@hooks/device'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = SearchProps & { + defaultHeight: number; + forwardedRef?: React.RefObject; + largeHeight: number; + scrollValue: Animated.SharedValue; + theme: Theme; + top: number; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: theme.sidebarBg, + height: SEARCH_INPUT_HEIGHT + 5, + justifyContent: 'flex-start', + paddingHorizontal: 20, + position: 'absolute', + width: '100%', + zIndex: 10, + }, +})); + +const NavigationSearch = ({ + defaultHeight, + forwardedRef, + largeHeight, + scrollValue, + theme, + top, + ...searchProps +}: Props) => { + const isTablet = useIsTablet(); + const styles = getStyleSheet(theme); + + const searchTop = useAnimatedStyle(() => { + return {marginTop: Math.max((-scrollValue.value + largeHeight), top)}; + }, [defaultHeight, largeHeight, top]); + + const onFocus = useCallback((e) => { + const searchInset = isTablet ? TABLET_HEADER_SEARCH_INSET : IOS_HEADER_SEARCH_INSET; + const offset = Platform.select({android: largeHeight + ANDROID_HEADER_SEARCH_INSET, default: defaultHeight + searchInset}); + if (forwardedRef?.current && Math.abs(scrollValue.value) <= top) { + if ((forwardedRef.current as ScrollView).scrollTo) { + (forwardedRef.current as ScrollView).scrollTo({y: offset, animated: true}); + } else { + (forwardedRef.current as VirtualizedList).scrollToOffset({ + offset, + animated: true, + }); + } + } + searchProps.onFocus?.(e); + }, [largeHeight, top]); + + return ( + + + + ); +}; + +export default NavigationSearch; + diff --git a/app/components/navigation_header/search_context.tsx b/app/components/navigation_header/search_context.tsx new file mode 100644 index 0000000000..57145f196a --- /dev/null +++ b/app/components/navigation_header/search_context.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; + +import {ANDROID_HEADER_SEARCH_INSET} from '@constants/view'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + defaultHeight: number; + largeHeight: number; + scrollValue: Animated.SharedValue; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: theme.sidebarBg, + height: 20, + position: 'absolute', + width: '100%', + }, +})); + +const NavigationHeaderSearchContext = ({ + defaultHeight, + largeHeight, + scrollValue, + theme, +}: Props) => { + const styles = getStyleSheet(theme); + + const marginTop = useAnimatedStyle(() => { + return {marginTop: (-scrollValue.value + largeHeight + defaultHeight) - ANDROID_HEADER_SEARCH_INSET}; + }, [defaultHeight, largeHeight]); + + return ( + + + + ); +}; + +export default NavigationHeaderSearchContext; + diff --git a/app/components/search/index.tsx b/app/components/search/index.tsx new file mode 100644 index 0000000000..2b6f275228 --- /dev/null +++ b/app/components/search/index.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {ActivityIndicatorProps, Platform, StyleProp, TextInput, TextInputProps, TextStyle, TouchableOpacityProps, ViewStyle} from 'react-native'; +import {SearchBar} from 'react-native-elements'; + +import CompassIcon from '@components/compass_icon'; +import {SEARCH_INPUT_HEIGHT} from '@constants/view'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +export type SearchProps = TextInputProps & { + cancelIcon?: React.ReactElement; + cancelButtonProps?: Partial & { + buttonStyle?: StyleProp; + buttonTextStyle?: StyleProp; + color?: string; + buttonDisabledStyle?: StyleProp; + buttonDisabledTextStyle?: StyleProp; + }; + cancelButtonTitle?: string; + clearIcon?: React.ReactElement; + containerStyle?: StyleProp; + inputContainerStyle?: StyleProp; + inputStyle?: StyleProp; + loadingProps?: ActivityIndicatorProps; + leftIconContainerStyle?: StyleProp; + onCancel?(): void; + onClear?(): void; + rightIconContainerStyle?: StyleProp; + searchIcon?: React.ReactElement; + showCancel?: boolean; + showLoading?: boolean; +}; + +type SearchRef = { + blur: () => void; + cancel: () => void; + clear: () => void; + focus: () => void; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + containerStyle: { + backgroundColor: undefined, + height: undefined, + paddingTop: 0, + paddingBottom: 0, + }, + inputContainerStyle: { + backgroundColor: changeOpacity(theme.sidebarText, 0.12), + borderRadius: 8, + height: SEARCH_INPUT_HEIGHT, + marginLeft: 0, + }, + inputStyle: { + color: theme.sidebarText, + marginLeft: Platform.select({ios: 6, android: 14}), + top: Platform.select({android: 1}), + ...typography('Body', 200, 'Regular'), + lineHeight: undefined, + }, +})); + +const Search = forwardRef((props: SearchProps, ref) => { + const intl = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const searchRef = useRef(null); + const [value, setValue] = useState(props.value || ''); + const searchClearButtonTestID = `${props.testID}.search.clear.button`; + const searchCancelButtonTestID = `${props.testID}.search.cancel.button`; + const searchInputTestID = `${props.testID}.search.input`; + + const onCancel = useCallback(() => { + setValue(''); + props.onCancel?.(); + }, []); + + const onClear = useCallback(() => { + setValue(''); + props.onClear?.(); + }, []); + + const onChangeText = useCallback((text: string) => { + setValue(text); + props.onChangeText?.(text); + }, []); + + const cancelButtonProps = useMemo(() => ({ + buttonTextStyle: { + color: changeOpacity(theme.sidebarText, 0.72), + ...typography('Body', 100, 'Regular'), + }, + }), [theme]); + + const clearIcon = useMemo(() => { + return ( + + ); + }, [searchRef.current, theme]); + + const searchIcon = useMemo(() => ( + + ), [theme]); + + const cancelIcon = useMemo(() => ( + + ), [searchRef.current, theme]); + + useImperativeHandle(ref, () => ({ + blur: () => { + searchRef.current?.blur(); + }, + cancel: () => { + // @ts-expect-error cancel is not part of TextInput does exist in SearchBar + searchRef.current?.cancel(); + }, + clear: () => { + searchRef.current?.clear(); + }, + focus: () => { + searchRef.current?.focus(); + }, + + }), [searchRef]); + + return ( + + ); +}); + +Search.displayName = 'SeachBar'; + +export default Search; diff --git a/app/components/touchable_with_feedback/touchable_with_feedback.android.tsx b/app/components/touchable_with_feedback/touchable_with_feedback.android.tsx index 53f2844272..c40521b88e 100644 --- a/app/components/touchable_with_feedback/touchable_with_feedback.android.tsx +++ b/app/components/touchable_with_feedback/touchable_with_feedback.android.tsx @@ -8,14 +8,16 @@ import {Touchable, TouchableOpacity, TouchableWithoutFeedback, View, StyleProp, import {TouchableNativeFeedback} from 'react-native-gesture-handler'; type TouchableProps = Touchable & { - testID: string; children: React.ReactNode | React.ReactNode[]; - underlayColor: string; - type: 'native' | 'opacity' | 'none'; + borderlessRipple?: boolean; + rippleRadius?: number; style?: StyleProp; + testID: string; + type: 'native' | 'opacity' | 'none'; + underlayColor: string; } -const TouchableWithFeedbackAndroid = ({testID, children, underlayColor, type = 'native', ...props}: TouchableProps) => { +const TouchableWithFeedbackAndroid = ({borderlessRipple = false, children, rippleRadius, testID, type = 'native', underlayColor, ...props}: TouchableProps) => { switch (type) { case 'native': return ( @@ -23,7 +25,7 @@ const TouchableWithFeedbackAndroid = ({testID, children, underlayColor, type = ' testID={testID} {...props} style={[props.style]} - background={TouchableNativeFeedback.Ripple(underlayColor || '#fff', false)} + background={TouchableNativeFeedback.Ripple(underlayColor || '#fff', borderlessRipple, rippleRadius)} > {children} diff --git a/app/constants/view.ts b/app/constants/view.ts index ad51205ddb..2c4158434b 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -1,11 +1,23 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Platform} from 'react-native'; + export const BOTTOM_TAB_ICON_SIZE = 31.2; export const PROFILE_PICTURE_SIZE = 32; export const PROFILE_PICTURE_EMOJI_SIZE = 28; +export const SEARCH_INPUT_HEIGHT = Platform.select({android: 40, ios: 36})!; export const TABLET_SIDEBAR_WIDTH = 320; export const TEAM_SIDEBAR_WIDTH = 72; +export const TABLET_HEADER_HEIGHT = 44; +export const IOS_DEFAULT_HEADER_HEIGHT = 50; +export const ANDROID_DEFAULT_HEADER_HEIGHT = 56; +export const LARGE_HEADER_TITLE = 60; +export const HEADER_WITH_SEARCH_HEIGHT = -16; +export const HEADER_WITH_SUBTITLE = 24; +export const IOS_HEADER_SEARCH_INSET = 20; +export const TABLET_HEADER_SEARCH_INSET = 28; +export const ANDROID_HEADER_SEARCH_INSET = 11; export default { BOTTOM_TAB_ICON_SIZE, @@ -13,6 +25,16 @@ export default { PROFILE_PICTURE_EMOJI_SIZE, DATA_SOURCE_USERS: 'users', DATA_SOURCE_CHANNELS: 'channels', + SEARCH_INPUT_HEIGHT, TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH, + TABLET_HEADER_HEIGHT, + IOS_DEFAULT_HEADER_HEIGHT, + ANDROID_DEFAULT_HEADER_HEIGHT, + LARGE_HEADER_TITLE, + HEADER_WITH_SEARCH_HEIGHT, + HEADER_WITH_SUBTITLE, + IOS_HEADER_SEARCH_INSET, + TABLET_HEADER_SEARCH_INSET, + ANDROID_HEADER_SEARCH_INSET, }; diff --git a/app/hooks/header.ts b/app/hooks/header.ts new file mode 100644 index 0000000000..d6bc2258e9 --- /dev/null +++ b/app/hooks/header.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {NativeScrollEvent, Platform} from 'react-native'; +import Animated, {scrollTo, useAnimatedRef, useAnimatedScrollHandler, useSharedValue} from 'react-native-reanimated'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import ViewConstants from '@constants/view'; +import {useIsTablet} from '@hooks/device'; + +type HeaderScrollContext = { + momentum?: number; + start?: number; +}; + +export const useDefaultHeaderHeight = () => { + const isTablet = useIsTablet(); + + if (isTablet) { + return ViewConstants.TABLET_HEADER_HEIGHT; + } + + if (Platform.OS === 'ios') { + return ViewConstants.IOS_DEFAULT_HEADER_HEIGHT; + } + + return ViewConstants.ANDROID_DEFAULT_HEADER_HEIGHT; +}; + +export const useLargeHeaderHeight = (hasLargeTitle: boolean, hasSubtitle: boolean, hasSearch: boolean) => { + const defaultHeight = useDefaultHeaderHeight(); + if (hasLargeTitle && hasSubtitle && !hasSearch) { + return defaultHeight + ViewConstants.LARGE_HEADER_TITLE + ViewConstants.HEADER_WITH_SUBTITLE; + } else if (hasLargeTitle && hasSearch) { + return defaultHeight + ViewConstants.LARGE_HEADER_TITLE + ViewConstants.HEADER_WITH_SEARCH_HEIGHT; + } + + return defaultHeight + ViewConstants.LARGE_HEADER_TITLE; +}; + +export const useHeaderHeight = (hasLargeTitle: boolean, hasSubtitle: boolean, hasSearch: boolean) => { + const defaultHeight = useDefaultHeaderHeight(); + const largeHeight = useLargeHeaderHeight(hasLargeTitle, hasSubtitle, hasSearch); + return useMemo(() => { + return { + defaultHeight, + largeHeight, + }; + }, [defaultHeight, hasSearch, largeHeight]); +}; + +export const useCollapsibleHeader = (isLargeTitle: boolean, hasSubtitle: boolean, hasSearch: boolean) => { + const insets = useSafeAreaInsets(); + const isTablet = useIsTablet(); + const animatedRef = useAnimatedRef(); + const {largeHeight, defaultHeight} = useHeaderHeight(true, hasSubtitle, hasSearch); + const scrollValue = useSharedValue(0); + + function snapIfNeeded(dir: string, offset: number) { + 'worklet'; + if (dir === 'up' && offset < defaultHeight) { + const diffHeight = largeHeight - defaultHeight; + let position = 0; + if (Platform.OS === 'ios') { + const searchInset = isTablet ? ViewConstants.TABLET_HEADER_SEARCH_INSET : ViewConstants.IOS_HEADER_SEARCH_INSET; + position = (diffHeight - (hasSearch ? -searchInset : insets.top)); + } else { + position = hasSearch ? largeHeight + ViewConstants.ANDROID_HEADER_SEARCH_INSET : diffHeight; + } + scrollTo(animatedRef, 0, position!, true); + } else if (dir === 'down') { + let inset = 0; + if (Platform.OS === 'ios') { + const searchInset = isTablet ? ViewConstants.TABLET_HEADER_SEARCH_INSET : ViewConstants.IOS_HEADER_SEARCH_INSET; + inset = defaultHeight + (hasSearch ? searchInset : 0); + } else { + inset = largeHeight + (hasSearch ? ViewConstants.ANDROID_HEADER_SEARCH_INSET : 0); + } + if (offset < inset) { + scrollTo(animatedRef, 0, -insets.top, true); + } + } + } + + const onScroll = useAnimatedScrollHandler({ + onBeginDrag: (e: NativeScrollEvent, ctx: HeaderScrollContext) => { + ctx.start = e.contentOffset.y; + }, + onScroll: (e) => { + scrollValue.value = e.contentOffset.y; + }, + onEndDrag: (e, ctx) => { + if (ctx.start !== undefined && Platform.OS === 'ios') { + const dir = e.contentOffset.y < ctx.start ? 'down' : 'up'; + const offset = Math.abs(e.contentOffset.y); + ctx.start = undefined; + snapIfNeeded(dir, offset); + } + }, + onMomentumBegin: (e, ctx) => { + ctx.momentum = Platform.OS === 'ios' ? e.contentOffset.y : ctx.start; + }, + onMomentumEnd: (e, ctx) => { + if (ctx.momentum !== undefined) { + const offset = Math.abs(e.contentOffset.y); + const searchInset = isTablet ? ViewConstants.TABLET_HEADER_SEARCH_INSET : ViewConstants.IOS_HEADER_SEARCH_INSET; + const dir = e.contentOffset.y < ctx.momentum ? 'down' : 'up'; + ctx.momentum = undefined; + + if (Platform.OS === 'android') { + // This avoids snapping to the defaultHeight when already at the top and scrolling down + if (dir === 'up' && offset === 0) { + return; + } + snapIfNeeded(dir, offset); + } else if (dir === 'down' && offset < (defaultHeight + (hasSearch ? searchInset : 0))) { + scrollTo(animatedRef, 0, -insets.top, true); + } + } + }, + }, [insets, defaultHeight, largeHeight]); + + let searchPadding = 0; + if (hasSearch) { + searchPadding = ViewConstants.SEARCH_INPUT_HEIGHT + + ViewConstants.IOS_HEADER_SEARCH_INSET + + ViewConstants.ANDROID_HEADER_SEARCH_INSET; + } + + return { + scrollPaddingTop: (isLargeTitle ? largeHeight : defaultHeight) + searchPadding, + scrollRef: animatedRef as unknown as React.RefObject, + scrollValue, + onScroll, + }; +}; + +export default useHeaderHeight; diff --git a/app/screens/home/search/index.tsx b/app/screens/home/search/index.tsx index e2eea827fc..de19a679d9 100644 --- a/app/screens/home/search/index.tsx +++ b/app/screens/home/search/index.tsx @@ -2,14 +2,26 @@ // See LICENSE.txt for license information. import {useIsFocused, useNavigation} from '@react-navigation/native'; -import React from 'react'; -import {Text} from 'react-native'; +import React, {useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {Text, FlatList, View, Platform} from 'react-native'; import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import {SafeAreaView} from 'react-native-safe-area-context'; +import Badge from '@components/badge'; +import NavigationHeader from '@components/navigation_header'; +import {useTheme} from '@context/theme'; +import {useCollapsibleHeader} from '@hooks/header'; + +import type {HeaderRightButton} from '@components/navigation_header/header'; + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); + const SearchScreen = () => { const nav = useNavigation(); const isFocused = useIsFocused(); + const theme = useTheme(); + const intl = useIntl(); const searchScreenIndex = 1; const stateIndex = nav.getState().index; @@ -27,19 +39,109 @@ const SearchScreen = () => { }; }, [isFocused, stateIndex]); + // Todo: Remove example + const isLargeTitle = true; + const subtitle = ''; + const title = 'Search'; + const hasSearch = true; + const showBackButton = false; + const addLeftComponent = false; + const addRightButtons = false; + let leftComponent; + let rightButtons: HeaderRightButton[] | undefined; + + if (addLeftComponent) { + leftComponent = ( + + + + ); + } + + if (addRightButtons) { + rightButtons = [{ + iconName: 'magnify', + onPress: () => true, + }, { + iconName: Platform.select({android: 'dots-vertical', default: 'dots-horizontal'}), + onPress: () => true, + rippleRadius: 15, + borderless: true, + buttonType: 'opacity', + }]; + } + + const {scrollPaddingTop, scrollRef, scrollValue, onScroll} = useCollapsibleHeader>(isLargeTitle, Boolean(subtitle), hasSearch); + const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop}), [scrollPaddingTop]); + const data = [ + 'Search Screen 1', + 'Search Screen 2', + 'Search Screen 3', + 'Search Screen 4', + 'Search Screen 5', + ]; + return ( - - + { + // eslint-disable-next-line no-console + console.log('BACK'); + }} + rightButtons={rightButtons} + showBackButton={showBackButton} + subtitle={subtitle} + title={title} + hasSearch={hasSearch} + scrollValue={scrollValue} + forwardedRef={scrollRef} + onChangeText={(text) => { + // eslint-disable-next-line no-console + console.log('Search for value', text); + }} + onSubmitEditing={() => { + // eslint-disable-next-line no-console + console.log('Execute search'); + }} + blurOnSubmit={true} + placeholder={intl.formatMessage({id: 'screen.search.placeholder', defaultMessage: 'Search messages & files'})} + /> + - {isFocused && - {'Search Screen'} - } - - + + { + const height = index === data.length - 1 ? undefined : 400; + return ( + + {item as string} + + ); + }} + /> + + + ); }; export default SearchScreen; + diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 5de99da654..e5d4a92b0a 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -227,8 +227,10 @@ "mobile.routes.sso": "Single Sign-On", "mobile.routes.table": "Table", "mobile.routes.user_profile": "Profile", + "mobile.server_identifier.exists": "You are already connected to this server.", "mobile.server_link.unreachable_channel.error": "This link belongs to a deleted channel or to a channel to which you do not have access.", "mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.", + "mobile.server_name.exists": "You are using this name for another server.", "mobile.server_ping_failed": "Cannot connect to the server.", "mobile.server_requires_client_certificate": "Server requires client certificate for authentication.", "mobile.server_upgrade.alert_description": "This server version is unsupported and users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {serverVersion} or later is required.", @@ -293,6 +295,7 @@ "post_info.system": "System", "post_message_view.edited": "(edited)", "posts_view.newMsg": "New Messages", + "screen.search.placeholder": "Search messages & files", "search_bar.search": "Search", "signup.email": "Email and Password", "signup.google": "Google Apps", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e752070b06..e2bc0ee141 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4,8 +4,6 @@ PODS: - BVLinearGradient (2.5.6): - React - DoubleConversion (1.1.6) - - EXErrorRecovery (3.0.3): - - ExpoModulesCore - EXFileSystem (13.0.3): - ExpoModulesCore - Expo (43.0.2): @@ -474,7 +472,6 @@ DEPENDENCIES: - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - - EXErrorRecovery (from `../node_modules/expo-error-recovery/ios`) - EXFileSystem (from `../node_modules/expo-file-system/ios`) - Expo (from `../node_modules/expo/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core/ios`) @@ -583,8 +580,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-linear-gradient" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" - EXErrorRecovery: - :path: "../node_modules/expo-error-recovery/ios" EXFileSystem: :path: "../node_modules/expo-file-system/ios" Expo: @@ -751,7 +746,6 @@ SPEC CHECKSUMS: boost: a7c83b31436843459a1961bfd74b96033dc77234 BVLinearGradient: e3aad03778a456d77928f594a649e96995f1c872 DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662 - EXErrorRecovery: ac2622400a32be84604591f70d0efff416e4b9a2 EXFileSystem: 99aac7962c11c680681819dd9cbca24e20e5b1e7 Expo: b66c9661cf2514b4adf67b8367178e8ed3eb9801 ExpoModulesCore: c353a3bed60c3b83dbe63f134aaa7bf4733ffa16 diff --git a/patches/react-native-elements+3.4.2.patch b/patches/react-native-elements+3.4.2.patch index 05113441a7..90f39bc6e9 100644 --- a/patches/react-native-elements+3.4.2.patch +++ b/patches/react-native-elements+3.4.2.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-elements/dist/searchbar/SearchBar-android.js b/node_modules/react-native-elements/dist/searchbar/SearchBar-android.js -index 1bfd2b4..820ccbc 100644 +index 1bfd2b4..436e870 100644 --- a/node_modules/react-native-elements/dist/searchbar/SearchBar-android.js +++ b/node_modules/react-native-elements/dist/searchbar/SearchBar-android.js @@ -10,7 +10,7 @@ var __rest = (this && this.__rest) || function (s, e) { @@ -11,7 +11,14 @@ index 1bfd2b4..820ccbc 100644 import { renderNode } from '../helpers'; import Input from '../input/Input'; import Icon from '../icons/Icon'; -@@ -74,18 +74,11 @@ class SearchBar extends Component { +@@ -68,24 +68,17 @@ class SearchBar extends Component { + }; + this.onBlur = (event) => { + this.props.onBlur(event); +- this.setState({ hasFocus: false }); ++ this.setState({ hasFocus: false, isEmpty: this.props.value === '' }); + }; + this.onChangeText = (text) => { this.props.onChangeText(text); this.setState({ isEmpty: text === '' }); }; @@ -31,7 +38,7 @@ index 1bfd2b4..820ccbc 100644 render() { var _a; diff --git a/node_modules/react-native-elements/dist/searchbar/SearchBar-ios.js b/node_modules/react-native-elements/dist/searchbar/SearchBar-ios.js -index 8fe90be..bb0e071 100644 +index 8fe90be..3daf517 100644 --- a/node_modules/react-native-elements/dist/searchbar/SearchBar-ios.js +++ b/node_modules/react-native-elements/dist/searchbar/SearchBar-ios.js @@ -85,6 +85,11 @@ class SearchBar extends Component { @@ -46,20 +53,3 @@ index 8fe90be..bb0e071 100644 render() { var _a, _b, _c, _d, _e, _f, _g; const _h = this.props, { theme, cancelButtonProps, cancelButtonTitle, clearIcon, containerStyle, leftIconContainerStyle, rightIconContainerStyle, inputContainerStyle, inputStyle, placeholderTextColor, showLoading, loadingProps, searchIcon, showCancel } = _h, attributes = __rest(_h, ["theme", "cancelButtonProps", "cancelButtonTitle", "clearIcon", "containerStyle", "leftIconContainerStyle", "rightIconContainerStyle", "inputContainerStyle", "inputStyle", "placeholderTextColor", "showLoading", "loadingProps", "searchIcon", "showCancel"]); -@@ -167,7 +172,6 @@ const styles = StyleSheet.create({ - paddingBottom: 13, - paddingTop: 13, - flexDirection: 'row', -- overflow: 'hidden', - alignItems: 'center', - }, - input: { -@@ -177,7 +181,7 @@ const styles = StyleSheet.create({ - inputContainer: { - borderBottomWidth: 0, - borderRadius: 9, -- minHeight: 36, -+ minHeight: 30, - marginLeft: 8, - marginRight: 8, - },