diff --git a/native/chat/multimedia-message-tooltip-button.react.js b/native/chat/multimedia-message-tooltip-button.react.js index 218de6f6e..4b2e5778f 100644 --- a/native/chat/multimedia-message-tooltip-button.react.js +++ b/native/chat/multimedia-message-tooltip-button.react.js @@ -1,200 +1,192 @@ // @flow import * as React from 'react'; -import Animated, { type SharedValue } from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import EmojiPicker from 'rn-emoji-keyboard'; import { localIDPrefix } from 'lib/shared/message-utils'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils'; -import type { SetState } from 'lib/types/hook-types'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { useSelector } from '../redux/redux-utils'; +import { useTooltipActions } from '../tooltip/tooltip-hooks'; import type { TooltipRoute } from '../tooltip/tooltip.react'; import { TooltipInlineEngagement } from './inline-engagement.react'; import { InnerMultimediaMessage } from './inner-multimedia-message.react'; import { MessageHeader } from './message-header.react'; import { useSendReaction, useReactionSelectionPopoverPosition, } from './reaction-message-utils'; import ReactionSelectionPopover from './reaction-selection-popover.react'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react'; import { useAnimatedMessageTooltipButton } from './utils'; /* eslint-disable import/no-named-as-default-member */ const { Node, Extrapolate, interpolateNode } = Animated; /* eslint-enable import/no-named-as-default-member */ function noop() {} type Props = { +navigation: AppNavigationProp<'MultimediaMessageTooltipModal'>, +route: TooltipRoute<'MultimediaMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, - +setHideTooltip: SetState, - +showEmojiKeyboard: SharedValue, }; function MultimediaMessageTooltipButton(props: Props): React.Node { - const { - navigation, - progress, - isOpeningSidebar, - setHideTooltip, - showEmojiKeyboard, - } = props; + const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); const [ sidebarInputBarHeight, setSidebarInputBarHeight, ] = React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); - const { - item, - verticalBounds, - initialCoordinates, - margin, - } = props.route.params; + const { item, verticalBounds, initialCoordinates, margin } = route.params; const { style: messageContainerStyle } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const inlineEngagement = React.useMemo(() => { if (!item.threadCreatedFromMessage) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const innerMultimediaMessage = React.useMemo( () => ( ), [item, navigation.goBackOnce, verticalBounds], ); const { messageInfo, threadInfo, reactions } = item; const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const reactionSelectionPopoverPosition = useReactionSelectionPopoverPosition({ initialCoordinates, verticalBounds, margin, }); + const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); + const openEmojiPicker = React.useCallback(() => { + setEmojiPickerOpen(true); + }, []); + const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ + navigation, + route, + openEmojiPicker, canCreateReactionFromMessage, reactionSelectionPopoverPosition, sendReaction, - setHideTooltip, - showEmojiKeyboard, ]); + const tooltipRouteKey = route.key; + const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); + const onEmojiSelected = React.useCallback( emoji => { sendReaction(emoji.emoji); - setHideTooltip(true); + dismissTooltip(); }, - [sendReaction, setHideTooltip], + [sendReaction, dismissTooltip], ); - const onCloseEmojiPicker = React.useCallback(() => { - showEmojiKeyboard.value = false; - navigation.goBackOnce(); - }, [navigation, showEmojiKeyboard]); - return ( <> {reactionSelectionPopover} {innerMultimediaMessage} {inlineEngagement} ); } export default MultimediaMessageTooltipButton; diff --git a/native/chat/reaction-selection-popover.react.js b/native/chat/reaction-selection-popover.react.js index b19ace095..1d9d08684 100644 --- a/native/chat/reaction-selection-popover.react.js +++ b/native/chat/reaction-selection-popover.react.js @@ -1,120 +1,129 @@ // @flow import * as React from 'react'; import { View, TouchableOpacity, Text } from 'react-native'; -import type { SharedValue } from 'react-native-reanimated'; - -import type { SetState } from 'lib/types/hook-types'; import SWMansionIcon from '../components/swmansion-icon.react'; +import type { AppNavigationProp } from '../navigation/app-navigator.react'; +import type { TooltipModalParamList } from '../navigation/route-names'; import { useStyles } from '../themes/colors'; +import { useTooltipActions } from '../tooltip/tooltip-hooks'; +import type { TooltipRoute } from '../tooltip/tooltip.react'; import type { ViewStyle } from '../types/styles'; -type ReactionSelectionPopoverProps = { - +setHideTooltip: SetState, - +showEmojiKeyboard: SharedValue, +type Props> = { + +navigation: AppNavigationProp, + +route: TooltipRoute, + +openEmojiPicker: () => mixed, +reactionSelectionPopoverContainerStyle: ViewStyle, +sendReaction: (reaction: string) => mixed, }; -function ReactionSelectionPopover( - props: ReactionSelectionPopoverProps, +function ReactionSelectionPopover>( + props: Props, ): React.Node { const { - setHideTooltip, - showEmojiKeyboard, + navigation, + route, + openEmojiPicker, reactionSelectionPopoverContainerStyle, sendReaction, } = props; const styles = useStyles(unboundStyles); const containerStyle = React.useMemo( () => [ styles.reactionSelectionPopoverContainer, reactionSelectionPopoverContainerStyle, ], [ reactionSelectionPopoverContainerStyle, styles.reactionSelectionPopoverContainer, ], ); + const tooltipRouteKey = route.key; + const { hideTooltip, dismissTooltip } = useTooltipActions( + navigation, + tooltipRouteKey, + ); + const onPressDefaultEmoji = React.useCallback( (emoji: string) => { sendReaction(emoji); - setHideTooltip(true); + dismissTooltip(); }, - [sendReaction, setHideTooltip], + [sendReaction, dismissTooltip], ); const onPressEmojiKeyboardButton = React.useCallback(() => { - showEmojiKeyboard.value = true; - setHideTooltip(true); - }, [setHideTooltip, showEmojiKeyboard]); + openEmojiPicker(); + hideTooltip(); + }, [openEmojiPicker, hideTooltip]); const defaultEmojis = React.useMemo(() => { const defaultEmojisData = ['❤️', '😆', '😮', '😠', '👍']; return defaultEmojisData.map(emoji => ( onPressDefaultEmoji(emoji)}> {emoji} )); }, [ onPressDefaultEmoji, styles.reactionSelectionItemContainer, styles.reactionSelectionItemEmoji, ]); return ( {defaultEmojis} ); } const unboundStyles = { reactionSelectionPopoverContainer: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'tooltipBackground', padding: 8, borderRadius: 8, flex: 1, }, reactionSelectionItemContainer: { backgroundColor: 'reactionSelectionPopoverItemBackground', justifyContent: 'center', alignItems: 'center', padding: 8, borderRadius: 20, width: 40, height: 40, marginRight: 12, }, reactionSelectionItemEmoji: { fontSize: 18, }, emojiKeyboardButtonContainer: { backgroundColor: 'reactionSelectionPopoverItemBackground', justifyContent: 'center', alignItems: 'center', padding: 8, borderRadius: 20, width: 40, height: 40, }, icon: { color: 'modalForegroundLabel', }, }; export default ReactionSelectionPopover; diff --git a/native/chat/robotext-message-tooltip-button.react.js b/native/chat/robotext-message-tooltip-button.react.js index e9129a1e1..c0ca30e42 100644 --- a/native/chat/robotext-message-tooltip-button.react.js +++ b/native/chat/robotext-message-tooltip-button.react.js @@ -1,184 +1,176 @@ // @flow import * as React from 'react'; -import Animated, { type SharedValue } from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import EmojiPicker from 'rn-emoji-keyboard'; import { localIDPrefix } from 'lib/shared/message-utils'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils'; -import type { SetState } from 'lib/types/hook-types'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { useSelector } from '../redux/redux-utils'; +import { useTooltipActions } from '../tooltip/tooltip-hooks'; import type { TooltipRoute } from '../tooltip/tooltip.react'; import { TooltipInlineEngagement } from './inline-engagement.react'; import { InnerRobotextMessage } from './inner-robotext-message.react'; import { useSendReaction, useReactionSelectionPopoverPosition, } from './reaction-message-utils'; import ReactionSelectionPopover from './reaction-selection-popover.react'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react'; import { Timestamp } from './timestamp.react'; import { useAnimatedMessageTooltipButton } from './utils'; /* eslint-disable import/no-named-as-default-member */ const { Node, interpolateNode, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = { +navigation: AppNavigationProp<'RobotextMessageTooltipModal'>, +route: TooltipRoute<'RobotextMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, - +setHideTooltip: SetState, - +showEmojiKeyboard: SharedValue, }; function RobotextMessageTooltipButton(props: Props): React.Node { - const { - navigation, - progress, - isOpeningSidebar, - setHideTooltip, - showEmojiKeyboard, - } = props; + const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); const [ sidebarInputBarHeight, setSidebarInputBarHeight, ] = React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); - const { - item, - verticalBounds, - initialCoordinates, - margin, - } = props.route.params; + const { item, verticalBounds, initialCoordinates, margin } = route.params; const { style: messageContainerStyle } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const inlineEngagement = React.useMemo(() => { if (!item.threadCreatedFromMessage) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const { messageInfo, threadInfo, reactions } = item; const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const reactionSelectionPopoverPosition = useReactionSelectionPopoverPosition({ initialCoordinates, verticalBounds, margin, }); + const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); + const openEmojiPicker = React.useCallback(() => { + setEmojiPickerOpen(true); + }, []); + const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ + navigation, + route, + openEmojiPicker, canCreateReactionFromMessage, reactionSelectionPopoverPosition, sendReaction, - setHideTooltip, - showEmojiKeyboard, ]); + const tooltipRouteKey = route.key; + const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); + const onEmojiSelected = React.useCallback( emoji => { sendReaction(emoji.emoji); - setHideTooltip(true); + dismissTooltip(); }, - [sendReaction, setHideTooltip], + [sendReaction, dismissTooltip], ); - const onCloseEmojiPicker = React.useCallback(() => { - showEmojiKeyboard.value = false; - navigation.goBackOnce(); - }, [navigation, showEmojiKeyboard]); - return ( <> {reactionSelectionPopover} {inlineEngagement} ); } export default RobotextMessageTooltipButton; diff --git a/native/chat/text-message-tooltip-button.react.js b/native/chat/text-message-tooltip-button.react.js index 362b99701..d52250223 100644 --- a/native/chat/text-message-tooltip-button.react.js +++ b/native/chat/text-message-tooltip-button.react.js @@ -1,208 +1,200 @@ // @flow import * as React from 'react'; -import Animated, { type SharedValue } from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import EmojiPicker from 'rn-emoji-keyboard'; import { localIDPrefix } from 'lib/shared/message-utils'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils'; -import type { SetState } from 'lib/types/hook-types'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { useSelector } from '../redux/redux-utils'; +import { useTooltipActions } from '../tooltip/tooltip-hooks'; import type { TooltipRoute } from '../tooltip/tooltip.react'; import { TooltipInlineEngagement } from './inline-engagement.react'; import { InnerTextMessage } from './inner-text-message.react'; import { MessageHeader } from './message-header.react'; import { MessageListContextProvider } from './message-list-types'; import { MessagePressResponderContext } from './message-press-responder-context'; import { useSendReaction, useReactionSelectionPopoverPosition, } from './reaction-message-utils'; import ReactionSelectionPopover from './reaction-selection-popover.react'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react'; import { useAnimatedMessageTooltipButton } from './utils'; /* eslint-disable import/no-named-as-default-member */ const { Node, interpolateNode, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = { +navigation: AppNavigationProp<'TextMessageTooltipModal'>, +route: TooltipRoute<'TextMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, - +setHideTooltip: SetState, - +showEmojiKeyboard: SharedValue, }; function TextMessageTooltipButton(props: Props): React.Node { - const { - navigation, - progress, - isOpeningSidebar, - setHideTooltip, - showEmojiKeyboard, - } = props; + const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); const [ sidebarInputBarHeight, setSidebarInputBarHeight, ] = React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); - const { - item, - verticalBounds, - initialCoordinates, - margin, - } = props.route.params; + const { item, verticalBounds, initialCoordinates, margin } = route.params; const { style: messageContainerStyle, threadColorOverride, isThreadColorDarkOverride, } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const threadID = item.threadInfo.id; const messagePressResponderContext = React.useMemo( () => ({ onPressMessage: navigation.goBackOnce, }), [navigation.goBackOnce], ); const inlineEngagement = React.useMemo(() => { if (!item.threadCreatedFromMessage) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const { messageInfo, threadInfo, reactions } = item; const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction( messageInfo.id, localID, threadInfo.id, reactions, ); const reactionSelectionPopoverPosition = useReactionSelectionPopoverPosition({ initialCoordinates, verticalBounds, margin, }); + const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); + const openEmojiPicker = React.useCallback(() => { + setEmojiPickerOpen(true); + }, []); + const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ + navigation, + route, + openEmojiPicker, canCreateReactionFromMessage, reactionSelectionPopoverPosition, sendReaction, - setHideTooltip, - showEmojiKeyboard, ]); + const tooltipRouteKey = route.key; + const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); + const onEmojiSelected = React.useCallback( emoji => { sendReaction(emoji.emoji); - setHideTooltip(true); + dismissTooltip(); }, - [sendReaction, setHideTooltip], + [sendReaction, dismissTooltip], ); - const onCloseEmojiPicker = React.useCallback(() => { - showEmojiKeyboard.value = false; - navigation.goBackOnce(); - }, [navigation, showEmojiKeyboard]); - return ( {reactionSelectionPopover} {inlineEngagement} ); } export default TextMessageTooltipButton; diff --git a/native/tooltip/tooltip-context.react.js b/native/tooltip/tooltip-context.react.js index 5c3143dd0..fb054f69a 100644 --- a/native/tooltip/tooltip-context.react.js +++ b/native/tooltip/tooltip-context.react.js @@ -1,186 +1,190 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import * as React from 'react'; import { Platform, StyleSheet } from 'react-native'; import type { TooltipItemBaseProps } from './tooltip-item.react'; type RegisterOptionInput = { ...TooltipItemBaseProps, +symbol: symbol, }; type RegisterOptionOutput = { +shouldRender: boolean }; export type TooltipContextType = { +registerOption: (input: RegisterOptionInput) => RegisterOptionOutput, +unregisterOption: (id: string) => void, +showActionSheet: () => void, +shouldShowMore: () => boolean, +getNumVisibleEntries: () => number, }; const TooltipContext: React.Context = React.createContext(); type ProviderProps = { +maxOptionsToDisplay: number, +visibleEntryIDs: ?$ReadOnlyArray, +cancel: () => mixed, + +hideTooltip: () => mixed, +children: React.Node, }; function TooltipContextProvider(props: ProviderProps): React.Node { const optionsRef = React.useRef([]); const { visibleEntryIDs } = props; const visibleEntryIDsSet = React.useMemo( () => new Set(visibleEntryIDs ?? []), [visibleEntryIDs], ); const { maxOptionsToDisplay } = props; const registerOption = React.useCallback( (input: RegisterOptionInput) => { const options = optionsRef.current; const existingIndex = options.findIndex(option => option.id === input.id); if (existingIndex === -1) { options.push(input); } else { if (options[existingIndex].symbol !== input.symbol) { console.warn(`multiple TooltipItems registered with ID ${input.id}`); } options[existingIndex] = input; } const optionsToDisplay = options.filter(option => visibleEntryIDsSet.has(option.id), ); const displayIndex = optionsToDisplay.findIndex( option => option.id === input.id, ); const cutoff = optionsToDisplay.length === maxOptionsToDisplay ? maxOptionsToDisplay : maxOptionsToDisplay - 1; const shouldRender = input.id === 'more' || (displayIndex >= 0 && displayIndex < cutoff); return { shouldRender }; }, [maxOptionsToDisplay, visibleEntryIDsSet], ); const unregisterOption = React.useCallback((id: string) => { optionsRef.current = optionsRef.current.filter(option => option.id !== id); }, []); - const { cancel } = props; + const { cancel, hideTooltip } = props; const { showActionSheetWithOptions } = useActionSheet(); const showActionSheet = React.useCallback(() => { + hideTooltip(); + const options = optionsRef.current; const optionsToDisplay = options.filter(option => visibleEntryIDsSet.has(option.id), ); const filteredOptions = optionsToDisplay.slice(maxOptionsToDisplay - 1); const cancelButtonExists = options.some(option => option.isCancel); if (Platform.OS === 'ios' && !cancelButtonExists) { filteredOptions.push({ id: 'cancel', text: 'Cancel', onPress: cancel, isCancel: true, symbol: Symbol(), }); } // We're reversing options to populate the action sheet from bottom to // top instead of the default (top to bottom) ordering. filteredOptions.reverse(); const texts = filteredOptions.map(option => option.text); const destructiveButtonIndices = filteredOptions .filter(option => option.isDestructive) .map((_, i) => i); const cancelButtonIndex = filteredOptions.findIndex( option => option.isCancel, ); const icons = filteredOptions.map(option => option.renderIcon ? option.renderIcon(styles.bottomSheetIcon) : undefined, ); const onPressAction = (selectedIndex: ?number) => { const index = selectedIndex ?? 0; filteredOptions[index].onPress(); }; const containerStyle = { paddingBottom: 24, }; showActionSheetWithOptions( { options: texts, cancelButtonIndex, destructiveButtonIndex: destructiveButtonIndices, containerStyle, icons, }, onPressAction, ); }, [ + hideTooltip, maxOptionsToDisplay, visibleEntryIDsSet, cancel, showActionSheetWithOptions, ]); const shouldShowMore = React.useCallback(() => { const options = optionsRef.current; const optionsToDisplay = options.filter(option => visibleEntryIDsSet.has(option.id), ); return optionsToDisplay.length > maxOptionsToDisplay; }, [maxOptionsToDisplay, visibleEntryIDsSet]); const getNumVisibleEntries = React.useCallback(() => { const options = optionsRef.current; const optionsToDisplay = options.filter(option => visibleEntryIDsSet.has(option.id), ); return Math.min(optionsToDisplay.length, maxOptionsToDisplay); }, [maxOptionsToDisplay, visibleEntryIDsSet]); const context = React.useMemo( () => ({ registerOption, unregisterOption, showActionSheet, shouldShowMore, getNumVisibleEntries, }), [ registerOption, unregisterOption, showActionSheet, shouldShowMore, getNumVisibleEntries, ], ); const { children } = props; return ( {children} ); } const styles = StyleSheet.create({ bottomSheetIcon: { color: '#000000', }, }); export { TooltipContext, TooltipContextProvider }; diff --git a/native/tooltip/tooltip-hooks.js b/native/tooltip/tooltip-hooks.js new file mode 100644 index 000000000..7600e4454 --- /dev/null +++ b/native/tooltip/tooltip-hooks.js @@ -0,0 +1,39 @@ +// @flow + +import * as React from 'react'; + +import type { AppNavigationProp } from '../navigation/app-navigator.react'; +import type { TooltipModalParamList } from '../navigation/route-names'; + +type TooltipActions = { + // Hiding will keep the Tooltip ReactNav screen open, which means that the + // background will still be dimmed. But it will hide the actual tooltip menu. + +hideTooltip: () => void, + // Dismiss the tooltip will dismiss the ReactNav screen. This will start the + // OverlayNavigator animation to dismiss the screen. + +dismissTooltip: () => void, +}; +function useTooltipActions>( + navigation: AppNavigationProp, + tooltipRouteKey: string, +): TooltipActions { + const { clearOverlayModals, setRouteParams } = navigation; + + const hideTooltip = React.useCallback(() => { + setRouteParams(tooltipRouteKey, { hideTooltip: true }); + }, [setRouteParams, tooltipRouteKey]); + + const dismissTooltip = React.useCallback(() => { + clearOverlayModals([tooltipRouteKey]); + }, [clearOverlayModals, tooltipRouteKey]); + + return React.useMemo( + () => ({ + hideTooltip, + dismissTooltip, + }), + [hideTooltip, dismissTooltip], + ); +} + +export { useTooltipActions }; diff --git a/native/tooltip/tooltip-item.react.js b/native/tooltip/tooltip-item.react.js index ac1908abd..81086560b 100644 --- a/native/tooltip/tooltip-item.react.js +++ b/native/tooltip/tooltip-item.react.js @@ -1,77 +1,77 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { TouchableOpacity } from 'react-native'; import { SingleLine } from '../components/single-line.react'; import { useStyles } from '../themes/colors'; import type { ViewStyle, TextStyle } from '../types/styles'; import { TooltipContext } from './tooltip-context.react'; export type TooltipItemBaseProps = { +id: string, +text: string, +onPress: () => mixed, +renderIcon?: (iconStyle: TextStyle) => React.Node, +isDestructive?: boolean, +isCancel?: boolean, }; type Props = { ...TooltipItemBaseProps, +containerStyle?: ViewStyle, - +closeTooltip: () => mixed, + +closeTooltip?: () => mixed, }; function TooltipItem(props: Props): React.Node { const tooltipContext = React.useContext(TooltipContext); invariant(tooltipContext, 'TooltipContext should be set in TooltipItem'); const { registerOption, unregisterOption } = tooltipContext; const symbolRef = React.useRef(Symbol()); const { containerStyle, closeTooltip, ...contextRegistrationInput } = props; const { shouldRender } = registerOption({ ...contextRegistrationInput, symbol: symbolRef.current, }); const { id, text, renderIcon, onPress: onPressItem } = props; React.useEffect(() => { return () => unregisterOption(id); }, [unregisterOption, id]); const styles = useStyles(unboundStyles); const onPress = React.useCallback(() => { onPressItem(); - closeTooltip(); + closeTooltip?.(); }, [onPressItem, closeTooltip]); if (!shouldRender) { return null; } const icon = renderIcon ? renderIcon(styles.icon) : null; return ( {icon} {text} ); } const unboundStyles = { label: { color: 'modalForegroundLabel', fontSize: 14, lineHeight: 17, textAlign: 'center', }, icon: { color: 'modalForegroundLabel', }, }; export default TooltipItem; diff --git a/native/tooltip/tooltip.react.js b/native/tooltip/tooltip.react.js index 98ef7ab04..e9b63f5ee 100644 --- a/native/tooltip/tooltip.react.js +++ b/native/tooltip/tooltip.react.js @@ -1,638 +1,594 @@ // @flow import type { RouteProp } from '@react-navigation/native'; import * as Haptics from 'expo-haptics'; import invariant from 'invariant'; import * as React from 'react'; import { View, TouchableWithoutFeedback, Platform, Keyboard, } from 'react-native'; -import Animated, { - SlideInDown, - SlideOutDown, - runOnJS, - useSharedValue, - type SharedValue, -} from 'react-native-reanimated'; - -import type { SetState } from 'lib/types/hook-types'; +import Animated, { SlideInDown, SlideOutDown } from 'react-native-reanimated'; import { ChatContext, type ChatContextType } from '../chat/chat-context'; import SWMansionIcon from '../components/swmansion-icon.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { TooltipModalParamList } from '../navigation/route-names'; import { type DimensionsInfo } from '../redux/dimensions-updater.react'; import { useSelector } from '../redux/redux-utils'; import { useStyles } from '../themes/colors'; import { type VerticalBounds, type LayoutCoordinates, } from '../types/layout-types'; import type { LayoutEvent } from '../types/react-native'; import { AnimatedView } from '../types/styles'; import { TooltipContextProvider, TooltipContext, type TooltipContextType, } from './tooltip-context.react'; import BaseTooltipItem, { type TooltipItemBaseProps, } from './tooltip-item.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, Extrapolate, add, multiply, interpolateNode } = Animated; /* eslint-enable import/no-named-as-default-member */ export type TooltipParams = { ...CustomProps, +presentedFrom: string, +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +tooltipLocation?: 'above' | 'below' | 'fixed', +margin?: number, +visibleEntryIDs?: $ReadOnlyArray, +chatInputBarHeight?: number, + +hideTooltip?: boolean, }; export type TooltipRoute> = RouteProp< TooltipModalParamList, RouteName, >; export type BaseTooltipProps = { +navigation: AppNavigationProp, +route: TooltipRoute, }; type ButtonProps = { ...Base, +progress: Node, +isOpeningSidebar: boolean, - +setHideTooltip: SetState, - +showEmojiKeyboard: SharedValue, }; type TooltipProps = { ...Base, // Redux state +dimensions: DimensionsInfo, +overlayContext: ?OverlayContextType, +chatContext: ?ChatContextType, - +actionSheetShown: SharedValue, - +hideTooltip: boolean, - +setHideTooltip: SetState, - +showEmojiKeyboard: SharedValue, - +exitAnimationWorklet: (finished: boolean) => void, +styles: typeof unboundStyles, +tooltipContext: TooltipContextType, +closeTooltip: () => mixed, +boundTooltipItem: React.ComponentType, }; export type TooltipMenuProps = { ...BaseTooltipProps, +tooltipItem: React.ComponentType, }; function createTooltip< RouteName: $Keys, BaseTooltipPropsType: BaseTooltipProps = BaseTooltipProps, >( ButtonComponent: React.ComponentType>, MenuComponent: React.ComponentType>, ): React.ComponentType { class Tooltip extends React.PureComponent< TooltipProps, > { backdropOpacity: Node; tooltipContainerOpacity: Node; tooltipVerticalAbove: Node; tooltipVerticalBelow: Node; tooltipHorizontalOffset: Value = new Value(0); tooltipHorizontal: Node; tooltipScale: Node; constructor(props: TooltipProps) { super(props); const { overlayContext } = props; invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; this.backdropOpacity = interpolateNode(position, { inputRange: [0, 1], outputRange: [0, 0.7], extrapolate: Extrapolate.CLAMP, }); this.tooltipContainerOpacity = interpolateNode(position, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const { margin } = this; this.tooltipVerticalAbove = interpolateNode(position, { inputRange: [0, 1], outputRange: [margin + this.tooltipHeight / 2, 0], extrapolate: Extrapolate.CLAMP, }); this.tooltipVerticalBelow = interpolateNode(position, { inputRange: [0, 1], outputRange: [-margin - this.tooltipHeight / 2, 0], extrapolate: Extrapolate.CLAMP, }); this.tooltipHorizontal = multiply( add(1, multiply(-1, position)), this.tooltipHorizontalOffset, ); this.tooltipScale = interpolateNode(position, { inputRange: [0, 0.2, 0.8, 1], outputRange: [0, 0, 1, 1], extrapolate: Extrapolate.CLAMP, }); } componentDidMount() { Haptics.impactAsync(); } get tooltipHeight(): number { if (this.props.route.params.tooltipLocation === 'fixed') { return fixedTooltipHeight; } else { return tooltipHeight(this.props.tooltipContext.getNumVisibleEntries()); } } get tooltipLocation(): 'above' | 'below' | 'fixed' { const { params } = this.props.route; const { tooltipLocation } = params; if (tooltipLocation) { return tooltipLocation; } const { initialCoordinates, verticalBounds } = params; const { y, height } = initialCoordinates; const contentTop = y; const contentBottom = y + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const { margin, tooltipHeight: curTooltipHeight } = this; const fullHeight = curTooltipHeight + margin; if ( contentBottom + fullHeight > boundsBottom && contentTop - fullHeight > boundsTop ) { return 'above'; } return 'below'; } get opacityStyle() { return { ...this.props.styles.backdrop, opacity: this.backdropOpacity, }; } get contentContainerStyle() { const { verticalBounds } = this.props.route.params; const fullScreenHeight = this.props.dimensions.height; const top = verticalBounds.y; const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height; return { ...this.props.styles.contentContainer, marginTop: top, marginBottom: bottom, }; } get buttonStyle() { const { params } = this.props.route; const { initialCoordinates, verticalBounds } = params; const { x, y, width, height } = initialCoordinates; return { width: Math.ceil(width), height: Math.ceil(height), marginTop: y - verticalBounds.y, marginLeft: x, }; } get margin() { const customMargin = this.props.route.params.margin; return customMargin !== null && customMargin !== undefined ? customMargin : 20; } get tooltipContainerStyle() { const { dimensions, route } = this.props; const { initialCoordinates, verticalBounds, chatInputBarHeight, } = route.params; const { x, y, width, height } = initialCoordinates; const { margin, tooltipLocation } = this; const style = {}; style.position = 'absolute'; (style.alignItems = 'center'), (style.opacity = this.tooltipContainerOpacity); if (tooltipLocation !== 'fixed') { style.transform = [{ translateX: this.tooltipHorizontal }]; } const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { style.left = 0; style.minWidth = width + 2 * extraLeftSpace; } else { style.right = 0; style.minWidth = width + 2 * extraRightSpace; } const inputBarHeight = chatInputBarHeight ?? 0; if (tooltipLocation === 'fixed') { const padding = 8; style.minWidth = dimensions.width - 16; style.left = 8; style.right = 8; style.bottom = dimensions.height - verticalBounds.height - verticalBounds.y - inputBarHeight + padding; } else if (tooltipLocation === 'above') { style.bottom = dimensions.height - Math.max(y, verticalBounds.y) + margin; style.transform.push({ translateY: this.tooltipVerticalAbove }); } else { style.top = Math.min(y + height, verticalBounds.y + verticalBounds.height) + margin; style.transform.push({ translateY: this.tooltipVerticalBelow }); } if (tooltipLocation !== 'fixed') { style.transform.push({ scale: this.tooltipScale }); } return style; } render() { const { dimensions, overlayContext, chatContext, - actionSheetShown, - hideTooltip, - setHideTooltip, - showEmojiKeyboard, - exitAnimationWorklet, styles, tooltipContext, closeTooltip, boundTooltipItem, ...navAndRouteForFlow } = this.props; const tooltipContainerStyle = [styles.itemContainer]; if (this.tooltipLocation === 'fixed') { tooltipContainerStyle.push(styles.itemContainerFixed); } const items = [ , ]; if (this.props.tooltipContext.shouldShowMore()) { items.push( , ); } let triangleStyle; const { route } = this.props; const { initialCoordinates } = route.params; const { x, width } = initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { triangleStyle = { alignSelf: 'flex-start', left: extraLeftSpace + (width - 20) / 2, }; } else { triangleStyle = { alignSelf: 'flex-end', right: extraRightSpace + (width - 20) / 2, }; } let triangleDown = null; let triangleUp = null; const { tooltipLocation } = this; if (tooltipLocation === 'above') { triangleDown = ; } else if (tooltipLocation === 'below') { triangleUp = ; } invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; const isOpeningSidebar = !!chatContext?.currentTransitionSidebarSourceID; const buttonProps: ButtonProps = { ...navAndRouteForFlow, progress: position, isOpeningSidebar, - setHideTooltip, - showEmojiKeyboard, }; const itemsStyles = [styles.items, styles.itemsFixed]; const animationDelay = Platform.OS === 'ios' ? 200 : 500; const enterAnimation = SlideInDown.delay(animationDelay); - - const exitAnimation = SlideOutDown.withCallback(exitAnimationWorklet); + const exitAnimation = SlideOutDown; let tooltip = null; if (this.tooltipLocation !== 'fixed') { tooltip = ( {triangleUp} {items} {triangleDown} ); } else if ( this.tooltipLocation === 'fixed' && - !hideTooltip && - !showEmojiKeyboard.value + !this.props.route.params.hideTooltip ) { tooltip = ( {items} ); } return ( {tooltip} ); } getTooltipItem() { const BoundTooltipItem = this.props.boundTooltipItem; return BoundTooltipItem; } onPressMore = () => { Keyboard.dismiss(); - this.props.actionSheetShown.value = true; - this.props.setHideTooltip(true); this.props.tooltipContext.showActionSheet(); }; renderMoreIcon = () => { const { styles } = this.props; return ( ); }; onTooltipContainerLayout = (event: LayoutEvent) => { const { route, dimensions } = this.props; const { x, width } = route.params.initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; const actualWidth = event.nativeEvent.layout.width; if (extraLeftSpace < extraRightSpace) { const minWidth = width + 2 * extraLeftSpace; this.tooltipHorizontalOffset.setValue((minWidth - actualWidth) / 2); } else { const minWidth = width + 2 * extraRightSpace; this.tooltipHorizontalOffset.setValue((actualWidth - minWidth) / 2); } }; } - function ConnectedTooltip(props: BaseTooltipPropsType) { + function ConnectedTooltip(props) { const dimensions = useSelector(state => state.dimensions); const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); - const actionSheetShown = useSharedValue(false); - const [hideTooltip, setHideTooltip] = React.useState(false); - - const showEmojiKeyboard = useSharedValue(false); - - const { goBackOnce } = props.navigation; - const goBackCallback = React.useCallback(() => { - if (!actionSheetShown.value) { - goBackOnce(); - } - }, [actionSheetShown.value, goBackOnce]); - - const exitAnimationWorklet = React.useCallback( - finished => { - 'worklet'; - if (finished) { - runOnJS(goBackCallback)(); - } - }, - [goBackCallback], - ); - const { params } = props.route; const { tooltipLocation } = params; const isFixed = tooltipLocation === 'fixed'; + const { hideTooltip, ...rest } = props; + + const { goBackOnce } = props.navigation; const closeTooltip = React.useCallback(() => { - if (isFixed && !actionSheetShown.value) { - setHideTooltip(true); - } else { - goBackOnce(); + goBackOnce(); + if (isFixed) { + hideTooltip(); } - showEmojiKeyboard.value = false; - }, [isFixed, actionSheetShown.value, goBackOnce, showEmojiKeyboard]); + }, [isFixed, hideTooltip, goBackOnce]); const styles = useStyles(unboundStyles); const boundTooltipItem = React.useCallback( innerProps => { const containerStyle = isFixed ? [styles.itemContainer, styles.itemContainerFixed] : styles.itemContainer; return ( ); }, [isFixed, styles, closeTooltip], ); const tooltipContext = React.useContext(TooltipContext); invariant(tooltipContext, 'TooltipContext should be set in Tooltip'); return ( ); } function MemoizedTooltip(props: BaseTooltipPropsType) { const { visibleEntryIDs } = props.route.params; const { goBackOnce } = props.navigation; + + const { setParams } = props.navigation; + const hideTooltip = React.useCallback(() => { + const paramsUpdate: any = { hideTooltip: true }; + setParams(paramsUpdate); + }, [setParams]); + return ( - + ); } return React.memo(MemoizedTooltip); } const unboundStyles = { backdrop: { backgroundColor: 'black', bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, container: { flex: 1, }, contentContainer: { flex: 1, overflow: 'hidden', }, icon: { color: 'modalForegroundLabel', }, itemContainer: { alignItems: 'center', flex: 1, flexDirection: 'row', justifyContent: 'center', padding: 10, }, itemContainerFixed: { flexDirection: 'column', }, items: { backgroundColor: 'tooltipBackground', borderRadius: 5, overflow: 'hidden', }, itemsFixed: { flex: 1, flexDirection: 'row', }, triangleDown: { borderBottomColor: 'transparent', borderBottomWidth: 0, borderLeftColor: 'transparent', borderLeftWidth: 10, borderRightColor: 'transparent', borderRightWidth: 10, borderStyle: 'solid', borderTopColor: 'tooltipBackground', borderTopWidth: 10, height: 10, top: Platform.OS === 'android' ? -1 : 0, width: 10, }, triangleUp: { borderBottomColor: 'tooltipBackground', borderBottomWidth: 10, borderLeftColor: 'transparent', borderLeftWidth: 10, borderRightColor: 'transparent', borderRightWidth: 10, borderStyle: 'solid', borderTopColor: 'transparent', borderTopWidth: 0, bottom: Platform.OS === 'android' ? -1 : 0, height: 10, width: 10, }, }; function tooltipHeight(numEntries: number): number { // 10 (triangle) + 37 * numEntries (entries) + numEntries - 1 (padding) return 9 + 38 * numEntries; } const fixedTooltipHeight: number = 53; export { createTooltip, fixedTooltipHeight };