diff --git a/native/tooltip/nux-tips-overlay.react.js b/native/tooltip/nux-tips-overlay.react.js new file mode 100644 --- /dev/null +++ b/native/tooltip/nux-tips-overlay.react.js @@ -0,0 +1,605 @@ +// @flow + +import type { RouteProp } from '@react-navigation/core'; +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 from 'react-native-reanimated'; + +import { + TooltipContextProvider, + TooltipContext, + type TooltipContextType, +} from './tooltip-context.react.js'; +import BaseTooltipItem, { + type TooltipItemBaseProps, +} from './tooltip-item.react.js'; +import { ChatContext, type ChatContextType } from '../chat/chat-context.js'; +import SWMansionIcon from '../components/swmansion-icon.react.js'; +import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; +import { + OverlayContext, + type OverlayContextType, +} from '../navigation/overlay-context.js'; +import type { TooltipModalParamList } from '../navigation/route-names.js'; +import { type DimensionsInfo } from '../redux/dimensions-updater.react.js'; +import { useSelector } from '../redux/redux-utils.js'; +import { useStyles } from '../themes/colors.js'; +import { + type VerticalBounds, + type LayoutCoordinates, +} from '../types/layout-types.js'; +import type { LayoutEvent } from '../types/react-native.js'; +import { + AnimatedView, + type ViewStyle, + type AnimatedViewStyle, + type WritableAnimatedStyleObj, + type ReanimatedTransform, +} from '../types/styles.js'; + +const { Value, Node, Extrapolate, add, multiply, interpolateNode } = Animated; + +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, + }, +}; + +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, +}; +type TooltipProps = { + ...Base, + // Redux state + +dimensions: DimensionsInfo, + +overlayContext: ?OverlayContextType, + +chatContext: ?ChatContextType, + +styles: $ReadOnly, + +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; + fixedTooltipVertical: 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, + }); + + const invertedPosition = add(1, multiply(-1, position)); + + this.tooltipHorizontal = multiply( + invertedPosition, + this.tooltipHorizontalOffset, + ); + + this.tooltipScale = interpolateNode(position, { + inputRange: [0, 0.2, 0.8, 1], + outputRange: [0, 0, 1, 1], + extrapolate: Extrapolate.CLAMP, + }); + + this.fixedTooltipVertical = multiply( + invertedPosition, + props.dimensions.height, + ); + } + + 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(): AnimatedViewStyle { + return { + ...this.props.styles.backdrop, + opacity: this.backdropOpacity, + }; + } + + get contentContainerStyle(): ViewStyle { + 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(): ViewStyle { + 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(): number { + const customMargin = this.props.route.params.margin; + return customMargin !== null && customMargin !== undefined + ? customMargin + : 20; + } + + get tooltipContainerStyle(): AnimatedViewStyle { + const { dimensions, route } = this.props; + const { initialCoordinates, verticalBounds, chatInputBarHeight } = + route.params; + const { x, y, width, height } = initialCoordinates; + const { margin, tooltipLocation } = this; + + const style: WritableAnimatedStyleObj = {}; + style.position = 'absolute'; + style.alignItems = 'center'; + style.opacity = this.tooltipContainerOpacity; + + const transform: Array = []; + + if (tooltipLocation !== 'fixed') { + transform.push({ 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; + transform.push({ translateY: this.fixedTooltipVertical }); + } else if (tooltipLocation === 'above') { + style.bottom = + dimensions.height - Math.max(y, verticalBounds.y) + margin; + transform.push({ translateY: this.tooltipVerticalAbove }); + } else { + style.top = + Math.min(y + height, verticalBounds.y + verticalBounds.height) + + margin; + transform.push({ translateY: this.tooltipVerticalBelow }); + } + + if (tooltipLocation !== 'fixed') { + transform.push({ scale: this.tooltipScale }); + } + + style.transform = transform; + + return style; + } + + render(): React.Node { + const { + dimensions, + overlayContext, + chatContext, + styles, + tooltipContext, + closeTooltip, + boundTooltipItem, + ...navAndRouteForFlow + } = this.props; + + const tooltipContainerStyle: Array = [styles.itemContainer]; + + if (this.tooltipLocation === 'fixed') { + tooltipContainerStyle.push(styles.itemContainerFixed); + } + + const items: Array = [ + , + ]; + + 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, + }; + + const itemsStyles = [styles.items, styles.itemsFixed]; + + let tooltip = null; + + if (this.tooltipLocation !== 'fixed') { + tooltip = ( + + {triangleUp} + {items} + {triangleDown} + + ); + } else if ( + this.tooltipLocation === 'fixed' && + !this.props.route.params.hideTooltip + ) { + tooltip = ( + + {items} + + ); + } + + return ( + + + + + + + + + {tooltip} + + + ); + } + + getTooltipItem(): React.ComponentType { + const BoundTooltipItem = this.props.boundTooltipItem; + return BoundTooltipItem; + } + + onPressMore = () => { + Keyboard.dismiss(); + this.props.tooltipContext.showActionSheet(); + }; + + renderMoreIcon = (): React.Node => { + 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: $ReadOnly<{ + ...BaseTooltipPropsType, + +hideTooltip: () => mixed, + }>, + ) { + const dimensions = useSelector(state => state.dimensions); + const overlayContext = React.useContext(OverlayContext); + const chatContext = React.useContext(ChatContext); + + const { params } = props.route; + const { tooltipLocation } = params; + const isFixed = tooltipLocation === 'fixed'; + + const { hideTooltip, ...rest } = props; + + const { goBackOnce } = props.navigation; + const closeTooltip = React.useCallback(() => { + goBackOnce(); + if (isFixed) { + hideTooltip(); + } + }, [isFixed, hideTooltip, goBackOnce]); + + const styles = useStyles(unboundStyles); + const boundTooltipItem = React.useCallback( + (innerProps: TooltipItemBaseProps) => { + 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); +} + +function tooltipHeight(numEntries: number): number { + // 10 (triangle) + 37 * numEntries (entries) + numEntries - 1 (padding) + return 9 + 38 * numEntries; +} + +const fixedTooltipHeight: number = 53; + +export { createTooltip, fixedTooltipHeight };