diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 40ab87055ca8..7102d6396381 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -205,6 +205,9 @@ const ONYXKEYS = { /** The end date (epoch timestamp) of the workspace owner’s grace period after the free trial ends. */ NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END: 'nvp_private_billingGracePeriodEnd', + /** The NVP containing all information related to educational tooltip in workspace chat */ + NVP_WORKSPACE_TOOLTIP: 'workspaceTooltip', + /** Does this user have push notifications enabled for this device? */ PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -873,6 +876,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_BILLING_FUND_ID]: number; [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; + [ONYXKEYS.NVP_WORKSPACE_TOOLTIP]: OnyxTypes.WorkspaceTooltip; [ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[]; [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflow; }; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx index c761faccad39..c2081fa33bd1 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx @@ -8,21 +8,21 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; type TransparentOverlayProps = { - resetSuggestions: () => void; + onPress: () => void; }; type OnPressHandler = PressableProps['onPress']; -function TransparentOverlay({resetSuggestions}: TransparentOverlayProps) { +function TransparentOverlay({onPress: onPressProp}: TransparentOverlayProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const onResetSuggestions = useCallback>( + const onPress = useCallback>( (event) => { event?.preventDefault(); - resetSuggestions(); + onPressProp(); }, - [resetSuggestions], + [onPressProp], ); const handlePointerDown = useCallback((e: PointerEvent) => { @@ -35,7 +35,7 @@ function TransparentOverlay({resetSuggestions}: TransparentOverlayProps) { style={styles.fullScreen} > ({left = 0, width = 0, bottom return ( - + {/* eslint-disable-next-line react/jsx-props-no-spreading */} diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx index d26dd0422368..4d322fe15c4e 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -39,7 +39,7 @@ function AutoCompleteSuggestionsPortal({ bodyElement && ReactDOM.createPortal( <> - + {componentToRender} , bodyElement, diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 8af6cd492c03..91dac789d7cf 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -19,6 +19,7 @@ import variables from '@styles/variables'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; +import type {TooltipAnchorAlignment} from '@src/types/utils/AnchorAlignment'; import type IconAsset from '@src/types/utils/IconAsset'; import Avatar from './Avatar'; import Badge from './Badge'; @@ -299,12 +300,18 @@ type MenuItemBaseProps = { /** Whether to show the tooltip */ shouldRenderTooltip?: boolean; - /** Whether to align the tooltip left */ - shouldForceRenderingTooltipLeft?: boolean; + /** Anchor alignment of the tooltip */ + tooltipAnchorAlignment?: TooltipAnchorAlignment; /** Additional styles for tooltip wrapper */ tooltipWrapperStyle?: StyleProp; + /** Any additional amount to manually adjust the horizontal position of the tooltip */ + tooltipShiftHorizontal?: number; + + /** Any additional amount to manually adjust the vertical position of the tooltip */ + tooltipShiftVertical?: number; + /** Render custom content inside the tooltip. */ renderTooltipContent?: () => ReactNode; @@ -398,8 +405,10 @@ function MenuItem( onBlur, avatarID, shouldRenderTooltip = false, - shouldForceRenderingTooltipLeft = false, + tooltipAnchorAlignment, tooltipWrapperStyle = {}, + tooltipShiftHorizontal = 0, + tooltipShiftVertical = 0, renderTooltipContent, }: MenuItemProps, ref: PressableRef, @@ -521,11 +530,12 @@ function MenuItem( )} diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 88ad2f6d5e00..a797f83b6c3b 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -1,3 +1,4 @@ +import {PortalHost} from '@gorhom/portal'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import ReactNativeModal from 'react-native-modal'; @@ -256,6 +257,7 @@ function BaseModal( customBackdrop={shouldUseCustomBackdrop ? : undefined} > + {}, }: BaseGenericTooltipProps) { // The width of tooltip's inner content. Has to be undefined in the beginning // as a width of 0 will cause the content to be rendered of a width of 0, @@ -35,21 +43,10 @@ function BaseGenericTooltip({ // The height of tooltip's wrapper. const [wrapperMeasuredHeight, setWrapperMeasuredHeight] = useState(); - const textContentRef = useRef(null); - const viewContentRef = useRef(null); const rootWrapper = useRef(null); const StyleUtils = useStyleUtils(); - // Measure content width - useEffect(() => { - if (!textContentRef.current && !viewContentRef.current) { - return; - } - const contentRef = viewContentRef.current ?? textContentRef.current; - contentRef?.measure((x, y, width) => setContentMeasuredWidth(width)); - }, []); - const {animationStyle, rootWrapperStyle, textStyle, pointerWrapperStyle, pointerStyle} = useMemo( () => StyleUtils.getTooltipStyles({ @@ -66,8 +63,9 @@ function BaseGenericTooltip({ manualShiftHorizontal: shiftHorizontal, manualShiftVertical: shiftVertical, shouldForceRenderingBelow, - shouldForceRenderingLeft, + anchorAlignment, wrapperStyle, + shouldAddHorizontalPadding: false, }), [ StyleUtils, @@ -83,47 +81,54 @@ function BaseGenericTooltip({ shiftHorizontal, shiftVertical, shouldForceRenderingBelow, - shouldForceRenderingLeft, + anchorAlignment, wrapperStyle, ], ); let content; if (renderTooltipContent) { - content = {renderTooltipContent()}; + content = {renderTooltipContent()}; } else { content = ( - - {text} - + {text} ); } return ( - { - const {height} = e.nativeEvent.layout; - if (height === wrapperMeasuredHeight) { - return; - } - setWrapperMeasuredHeight(height); - }} - > - {content} - - - - + + {shouldUseOverlay && } + { + const {height} = e.nativeEvent.layout; + if (height === wrapperMeasuredHeight) { + return; + } + setWrapperMeasuredHeight(height); + // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. + const target = e.target; + setTimeout(() => { + InteractionManager.runAfterInteractions(() => { + target.measure((x, y, width) => { + setContentMeasuredWidth(width); + }); + }); + }, CONST.ANIMATED_TRANSITION); + }} + > + {content} + + + + + ); } diff --git a/src/components/Tooltip/BaseGenericTooltip/index.tsx b/src/components/Tooltip/BaseGenericTooltip/index.tsx index bb02e17f07d9..41f3e97c8087 100644 --- a/src/components/Tooltip/BaseGenericTooltip/index.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.tsx @@ -1,8 +1,10 @@ import React, {useLayoutEffect, useMemo, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; import {Animated, View} from 'react-native'; +import TransparentOverlay from '@components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; +import CONST from '@src/CONST'; import textRef from '@src/types/utils/textRef'; import viewRef from '@src/types/utils/viewRef'; import type {BaseGenericTooltipProps} from './types'; @@ -27,7 +29,12 @@ function BaseGenericTooltip({ renderTooltipContent, shouldForceRenderingBelow = false, wrapperStyle = {}, - shouldForceRenderingLeft = false, + anchorAlignment = { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }, + shouldUseOverlay = false, + onPressOverlay = () => {}, }: BaseGenericTooltipProps) { // The width of tooltip's inner content. Has to be undefined in the beginning // as a width of 0 will cause the content to be rendered of a width of 0, @@ -63,7 +70,7 @@ function BaseGenericTooltip({ manualShiftHorizontal: shiftHorizontal, manualShiftVertical: shiftVertical, shouldForceRenderingBelow, - shouldForceRenderingLeft, + anchorAlignment, wrapperStyle, }), [ @@ -80,7 +87,7 @@ function BaseGenericTooltip({ shiftHorizontal, shiftVertical, shouldForceRenderingBelow, - shouldForceRenderingLeft, + anchorAlignment, wrapperStyle, ], ); @@ -111,15 +118,18 @@ function BaseGenericTooltip({ } return ReactDOM.createPortal( - - {content} - - - - , + <> + {shouldUseOverlay && } + + {content} + + + + + , body, ); } diff --git a/src/components/Tooltip/BaseGenericTooltip/types.ts b/src/components/Tooltip/BaseGenericTooltip/types.ts index 662905fc1ec6..41e689618902 100644 --- a/src/components/Tooltip/BaseGenericTooltip/types.ts +++ b/src/components/Tooltip/BaseGenericTooltip/types.ts @@ -1,5 +1,5 @@ import type {Animated} from 'react-native'; -import type TooltipProps from '@components/Tooltip/types'; +import type {SharedTooltipProps} from '@components/Tooltip/types'; type BaseGenericTooltipProps = { /** Window width */ @@ -27,7 +27,10 @@ type BaseGenericTooltipProps = { /** Any additional amount to manually adjust the vertical position of the tooltip. A positive value shifts the tooltip down, and a negative value shifts it up. */ shiftVertical?: number; -} & Pick; +} & Pick< + SharedTooltipProps, + 'renderTooltipContent' | 'maxWidth' | 'numberOfLines' | 'text' | 'shouldForceRenderingBelow' | 'wrapperStyle' | 'anchorAlignment' | 'shouldUseOverlay' | 'onPressOverlay' +>; // eslint-disable-next-line import/prefer-default-export export type {BaseGenericTooltipProps}; diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index cb158150fc88..b5e93a1ce595 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -1,14 +1,15 @@ import React, {memo, useEffect, useRef} from 'react'; +import {InteractionManager} from 'react-native'; import type {LayoutChangeEvent} from 'react-native'; import GenericTooltip from '@components/Tooltip/GenericTooltip'; -import type TooltipProps from '@components/Tooltip/types'; -import getBounds from './getBounds'; +import type {EducationalTooltipProps} from '@components/Tooltip/types'; +import CONST from '@src/CONST'; /** * A component used to wrap an element intended for displaying a tooltip. * This tooltip would show immediately without user's interaction and hide after 5 seconds. */ -function BaseEducationalTooltip({children, ...props}: TooltipProps) { +function BaseEducationalTooltip({children, shouldAutoDismiss = false, ...props}: EducationalTooltipProps) { const hideTooltipRef = useRef<() => void>(); useEffect( @@ -24,15 +25,15 @@ function BaseEducationalTooltip({children, ...props}: TooltipProps) { // Automatically hide tooltip after 5 seconds useEffect(() => { - if (!hideTooltipRef.current) { + if (!hideTooltipRef.current || !shouldAutoDismiss) { return; } - const intervalID = setInterval(hideTooltipRef.current, 5000); + const timerID = setTimeout(hideTooltipRef.current, 5000); return () => { - clearInterval(intervalID); + clearTimeout(timerID); }; - }, []); + }, [shouldAutoDismiss]); return ( { - updateTargetBounds(getBounds(e)); - showTooltip(); + // e.target is specific to native, use e.nativeEvent.target on web instead + const target = e.target || e.nativeEvent.target; + // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. + setTimeout(() => { + InteractionManager.runAfterInteractions(() => { + target?.measure((fx, fy, width, height, px, py) => { + updateTargetBounds({ + height, + width, + x: px, + y: py, + }); + showTooltip(); + }); + }); + }, CONST.ANIMATED_TRANSITION); }, }); }} diff --git a/src/components/Tooltip/EducationalTooltip/getBounds/index.native.ts b/src/components/Tooltip/EducationalTooltip/getBounds/index.native.ts deleted file mode 100644 index 44e34ba5ff21..000000000000 --- a/src/components/Tooltip/EducationalTooltip/getBounds/index.native.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {LayoutChangeEvent} from 'react-native'; -import type GetBounds from './types'; - -const getBounds: GetBounds = (event: LayoutChangeEvent) => event.nativeEvent.layout; - -export default getBounds; diff --git a/src/components/Tooltip/EducationalTooltip/getBounds/index.ts b/src/components/Tooltip/EducationalTooltip/getBounds/index.ts deleted file mode 100644 index d94949277740..000000000000 --- a/src/components/Tooltip/EducationalTooltip/getBounds/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {LayoutChangeEvent} from 'react-native'; -import type GetBounds from './types'; - -const getBounds: GetBounds = (event: LayoutChangeEvent) => (event.nativeEvent.target as HTMLElement).getBoundingClientRect(); - -export default getBounds; diff --git a/src/components/Tooltip/EducationalTooltip/getBounds/types.ts b/src/components/Tooltip/EducationalTooltip/getBounds/types.ts deleted file mode 100644 index 081962166ff1..000000000000 --- a/src/components/Tooltip/EducationalTooltip/getBounds/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type {LayoutChangeEvent, LayoutRectangle} from 'react-native'; - -type GetBounds = (event: LayoutChangeEvent) => LayoutRectangle; - -export default GetBounds; diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx index 2b48fa91141f..c41bc5650f1d 100644 --- a/src/components/Tooltip/GenericTooltip.tsx +++ b/src/components/Tooltip/GenericTooltip.tsx @@ -29,8 +29,13 @@ function GenericTooltip({ shiftVertical = 0, shouldForceRenderingBelow = false, wrapperStyle = {}, - shouldForceRenderingLeft = false, + anchorAlignment = { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }, shouldForceAnimate = false, + shouldUseOverlay: shouldUseOverlayProp = false, + onPressOverlay: onPressOverlayProp = () => {}, }: GenericTooltipProps) { const {preferredLocale} = useLocalize(); const {windowWidth} = useWindowDimensions(); @@ -51,6 +56,9 @@ function GenericTooltip({ const [wrapperWidth, setWrapperWidth] = useState(0); const [wrapperHeight, setWrapperHeight] = useState(0); + // Transparent overlay should disappear once user taps it + const [shouldUseOverlay, setShouldUseOverlay] = useState(shouldUseOverlayProp); + // Whether the tooltip is first tooltip to activate the TooltipSense const isTooltipSenseInitiator = useRef(false); const animation = useRef(new Animated.Value(0)); @@ -136,6 +144,15 @@ function GenericTooltip({ setIsVisible(false); }, []); + const onPressOverlay = useCallback(() => { + if (!shouldUseOverlay) { + return; + } + setShouldUseOverlay(false); + hideTooltip(); + onPressOverlayProp(); + }, [shouldUseOverlay, onPressOverlayProp, hideTooltip]); + useImperativeHandle(TooltipRefManager.ref, () => ({hideTooltip}), [hideTooltip]); // Skip the tooltip and return the children if the text is empty, we don't have a render function. @@ -164,7 +181,9 @@ function GenericTooltip({ key={[text, ...renderTooltipContentKey, preferredLocale].join('-')} shouldForceRenderingBelow={shouldForceRenderingBelow} wrapperStyle={wrapperStyle} - shouldForceRenderingLeft={shouldForceRenderingLeft} + anchorAlignment={anchorAlignment} + shouldUseOverlay={shouldUseOverlay} + onPressOverlay={onPressOverlay} /> )} diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index cf2218abf5b3..4165b960f322 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -1,6 +1,7 @@ import type {ReactNode} from 'react'; import type React from 'react'; import type {LayoutRectangle, StyleProp, ViewStyle} from 'react-native'; +import type {TooltipAnchorAlignment} from '@src/types/utils/AnchorAlignment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; type SharedTooltipProps = { @@ -27,14 +28,20 @@ type SharedTooltipProps = { /** Unique key of renderTooltipContent to rerender the tooltip when one of the key changes */ renderTooltipContentKey?: string[]; - /** Whether to left align the tooltip relative to wrapped component */ - shouldForceRenderingLeft?: boolean; + /** The anchor alignment of the tooltip */ + anchorAlignment?: TooltipAnchorAlignment; /** Whether to display tooltip below the wrapped component */ shouldForceRenderingBelow?: boolean; /** Additional styles for tooltip wrapper view */ wrapperStyle?: StyleProp; + + /** Should render a fullscreen transparent overlay */ + shouldUseOverlay?: boolean; + + /** Callback to fire when the transparent overlay is pressed */ + onPressOverlay?: () => void; }; type GenericTooltipState = { @@ -64,7 +71,11 @@ type TooltipProps = ChildrenProps & shouldHandleScroll?: boolean; }; -type EducationalTooltipProps = ChildrenProps & TooltipProps; +type EducationalTooltipProps = ChildrenProps & + SharedTooltipProps & { + /** Whether to automatically dismiss the tooltip after 5 seconds */ + shouldAutoDismiss?: boolean; + }; type TooltipExtendedProps = (EducationalTooltipProps | TooltipProps) & { /** Whether the actual Tooltip should be rendered. If false, it's just going to return the children */ @@ -72,4 +83,4 @@ type TooltipExtendedProps = (EducationalTooltipProps | TooltipProps) & { }; export default TooltipProps; -export type {EducationalTooltipProps, GenericTooltipProps, TooltipExtendedProps}; +export type {EducationalTooltipProps, GenericTooltipProps, SharedTooltipProps, TooltipExtendedProps}; diff --git a/src/languages/en.ts b/src/languages/en.ts index 83629e8ccaba..72df3a16d2a7 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -519,6 +519,10 @@ export default { emoji: 'Emoji', collapse: 'Collapse', expand: 'Expand', + tooltip: { + title: 'Get started!', + subtitle: ' Submit your first expense', + }, }, reportActionContextMenu: { copyToClipboard: 'Copy to clipboard', diff --git a/src/languages/es.ts b/src/languages/es.ts index f5ce103ef098..68ab4276d06c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -510,6 +510,10 @@ export default { emoji: 'Emoji', collapse: 'Colapsar', expand: 'Expandir', + tooltip: { + title: '¡Empecemos!', + subtitle: ' Presenta tu primer gasto', + }, }, reportActionContextMenu: { copyToClipboard: 'Copiar al portapapeles', diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 3019e3dfbb6c..56affbab9de6 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1033,6 +1033,10 @@ function dismissTrackTrainingModal() { }); } +function dismissWorkspaceTooltip() { + Onyx.merge(ONYXKEYS.NVP_WORKSPACE_TOOLTIP, {shouldShow: false}); +} + function requestRefund() { API.write(WRITE_COMMANDS.REQUEST_REFUND, null); } @@ -1042,6 +1046,7 @@ export { closeAccount, dismissReferralBanner, dismissTrackTrainingModal, + dismissWorkspaceTooltip, resendValidateCode, requestContactMethodValidateCode, updateNewsletterSubscription, diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 94089f880c92..5af4e30f07f1 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -125,6 +125,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro selector: (parentReportActions) => getParentReportAction(parentReportActions, reportOnyx?.parentReportActionID ?? ''), }); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [workspaceTooltip] = useOnyx(ONYXKEYS.NVP_WORKSPACE_TOOLTIP); const wasLoadingApp = usePrevious(isLoadingApp); const finishedLoadingApp = wasLoadingApp && !isLoadingApp; const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(parentReportAction); @@ -809,6 +810,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro isComposerFullSize={!!isComposerFullSize} isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} + workspaceTooltip={workspaceTooltip} /> ) : null} diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 3a57f057a938..dbfe684ea062 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,3 +1,4 @@ +import {useNavigation} from '@react-navigation/native'; import type {SyntheticEvent} from 'react'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native'; @@ -10,10 +11,14 @@ import type {FileObject} from '@components/AttachmentModal'; import AttachmentModal from '@components/AttachmentModal'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; import type {Mention} from '@components/MentionSuggestions'; import OfflineIndicator from '@components/OfflineIndicator'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxProvider'; +import Text from '@components/Text'; +import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; @@ -21,6 +26,7 @@ import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLen import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; @@ -32,6 +38,7 @@ import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutsi import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime'; import ReportDropUI from '@pages/home/report/ReportDropUI'; import ReportTypingIndicator from '@pages/home/report/ReportTypingIndicator'; +import variables from '@styles/variables'; import * as EmojiPickerActions from '@userActions/EmojiPickerAction'; import * as Report from '@userActions/Report'; import * as User from '@userActions/User'; @@ -94,6 +101,9 @@ type ReportActionComposeProps = ReportActionComposeOnyxProps & /** Should the input be disabled */ disabled?: boolean; + + /** Should show educational tooltip */ + shouldShowEducationalTooltip?: boolean; }; // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will @@ -115,9 +125,11 @@ function ReportActionCompose({ isReportReadyForDisplay = true, isEmptyChat, lastReportAction, + shouldShowEducationalTooltip, onComposerFocus, onComposerBlur, }: ReportActionComposeProps) { + const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); @@ -125,6 +137,7 @@ function ReportActionCompose({ const animatedRef = useAnimatedRef(); const actionButtonRef = useRef(null); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; + const navigation = useNavigation(); /** * Updates the Highlight state of the composer @@ -134,6 +147,7 @@ function ReportActionCompose({ return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; }); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); + const [shouldHideEducationalTooltip, setShouldHideEducationalTooltip] = useState(false); // A flag to indicate whether the onScroll callback is likely triggered by a layout change (caused by text change) or not const isScrollLikelyLayoutTriggered = useRef(false); @@ -346,6 +360,13 @@ function ReportActionCompose({ [], ); + useEffect(() => { + const unsubscribe = navigation.addListener('blur', () => { + setShouldHideEducationalTooltip(true); + }); + return unsubscribe; + }, [navigation]); + // When we invite someone to a room they don't have the policy object, but we still want them to be able to mention other reports they are members of, so we only check if the policyID in the report is from a workspace const isGroupPolicyReport = useMemo(() => !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE, [report]); const reportRecipientAcountIDs = ReportUtils.getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); @@ -377,6 +398,34 @@ function ReportActionCompose({ return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; }, [styles]); + const renderWorkspaceChatTooltip = useCallback( + () => ( + + + + {translate('reportActionCompose.tooltip.title')} + {translate('reportActionCompose.tooltip.subtitle')} + + + ), + [ + styles.alignItemsCenter, + styles.flexRow, + styles.justifyContentCenter, + styles.flexWrap, + styles.textAlignCenter, + styles.gap1, + styles.quickActionTooltipTitle, + styles.quickActionTooltipSubtitle, + theme.tooltipHighlightText, + translate, + ], + ); + return ( @@ -389,115 +438,129 @@ function ReportActionCompose({ style={isComposerFullSize ? styles.chatItemFullComposeRow : {}} contentContainerStyle={isComposerFullSize ? styles.flex1 : {}} > - User.dismissWorkspaceTooltip()} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + wrapperStyle={styles.reportActionComposeTooltipWrapper} + shiftHorizontal={variables.composerTooltipShiftHorizontal} + shiftVertical={variables.composerTooltipShiftVertical} > - setIsAttachmentPreviewActive(true)} - onModalHide={onAttachmentPreviewClose} + - {({displayFileInModal}) => ( - <> - { - isNextModalWillOpenRef.current = false; - restoreKeyboardState(); - }} - onMenuClosed={restoreKeyboardState} - onAddActionPressed={onAddActionPressed} - onItemSelected={onItemSelected} - actionButtonRef={actionButtonRef} - /> - { - if (value.length === 0 && isComposerFullSize) { - Report.setIsComposerFullSize(reportID, false); - } - validateCommentMaxLength(value, {reportID}); - }} - /> - { - if (isAttachmentPreviewActive) { - return; - } - const data = event.dataTransfer?.items[0]; - displayFileInModal(data as unknown as FileObject); - }} - /> - + setIsAttachmentPreviewActive(true)} + onModalHide={onAttachmentPreviewClose} + > + {({displayFileInModal}) => ( + <> + { + isNextModalWillOpenRef.current = false; + restoreKeyboardState(); + }} + onMenuClosed={restoreKeyboardState} + onAddActionPressed={onAddActionPressed} + onItemSelected={onItemSelected} + actionButtonRef={actionButtonRef} + /> + { + if (value.length === 0 && isComposerFullSize) { + Report.setIsComposerFullSize(reportID, false); + } + validateCommentMaxLength(value, {reportID}); + }} + /> + { + if (isAttachmentPreviewActive) { + return; + } + const data = event.dataTransfer?.items[0]; + displayFileInModal(data as unknown as FileObject); + }} + /> + + )} + + {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( + { + if (isNavigating) { + return; + } + focus(); + }} + onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} + emojiPickerID={report?.reportID} + shiftVertical={emojiShiftVertical} + /> )} - - {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( - { - if (isNavigating) { - return; - } - focus(); - }} - onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} - emojiPickerID={report?.reportID} - shiftVertical={emojiShiftVertical} + - )} - - + + ; + /** Whether to show educational tooltip in workspace chat for first-time user */ + workspaceTooltip: OnyxEntry; + /** Whether the chat is empty */ isEmptyChat?: boolean; @@ -71,6 +75,7 @@ function ReportFooter({ isEmptyChat = true, isReportReadyForDisplay = true, isComposerFullSize = false, + workspaceTooltip, onComposerBlur, onComposerFocus, }: ReportFooterProps) { @@ -79,6 +84,7 @@ function ReportFooter({ const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); const [shouldShowComposeInput] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, {initialValue: false}); const [isAnonymousUser = false] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS}); @@ -110,6 +116,7 @@ function ReportFooter({ const isSystemChat = ReportUtils.isSystemChat(report); const isAdminsOnlyPostingRoom = ReportUtils.isAdminsOnlyPostingRoom(report); const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); + const shouldShowEducationalTooltip = !!workspaceTooltip?.shouldShow && !isUserPolicyAdmin; const allPersonalDetails = usePersonalDetails(); @@ -210,6 +217,7 @@ function ReportFooter({ pendingAction={pendingAction} isComposerFullSize={isComposerFullSize} isReportReadyForDisplay={isReportReadyForDisplay} + shouldShowEducationalTooltip={didScreenTransitionEnd && shouldShowEducationalTooltip} /> @@ -229,5 +237,6 @@ export default memo( prevProps.isEmptyChat === nextProps.isEmptyChat && prevProps.lastReportAction === nextProps.lastReportAction && prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && + prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow && lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata), ); diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index ce983b9a16e7..8cad6d2fc45d 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -489,8 +489,13 @@ function FloatingActionButtonAndPopover( numberOfLinesDescription: 1, onSelected: () => interceptAnonymousUser(() => navigateToQuickAction()), shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport), - shouldRenderTooltip: quickAction?.isFirstQuickAction, - shouldForceRenderingTooltipLeft: true, + shouldRenderTooltip: quickAction.isFirstQuickAction, + tooltipAnchorAlignment: { + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + }, + tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal, + tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2, renderTooltipContent: renderQuickActionTooltip, tooltipWrapperStyle: styles.quickActionTooltipWrapper, }, diff --git a/src/styles/index.ts b/src/styles/index.ts index 6095855bbab2..59803541bfef 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3879,6 +3879,12 @@ const styles = (theme: ThemeColors) => ...wordBreak.breakWord, }, + reportActionComposeTooltipWrapper: { + backgroundColor: theme.tooltipHighlightBG, + paddingVertical: 8, + borderRadius: variables.componentBorderRadiusMedium, + }, + quickActionTooltipWrapper: { backgroundColor: theme.tooltipHighlightBG, }, diff --git a/src/styles/utils/generators/TooltipStyleUtils/computeHorizontalShift/index.native.ts b/src/styles/utils/generators/TooltipStyleUtils/computeHorizontalShift/index.native.ts new file mode 100644 index 000000000000..377cb3dbdfc0 --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/computeHorizontalShift/index.native.ts @@ -0,0 +1,5 @@ +import type {ComputeHorizontalShift} from './types'; + +const computeHorizontalShift: ComputeHorizontalShift = () => 0; + +export default computeHorizontalShift; diff --git a/src/styles/utils/generators/TooltipStyleUtils/computeHorizontalShift/index.ts b/src/styles/utils/generators/TooltipStyleUtils/computeHorizontalShift/index.ts new file mode 100644 index 000000000000..dbbdc52d5b9a --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/computeHorizontalShift/index.ts @@ -0,0 +1,42 @@ +import roundToNearestMultipleOfFour from '@libs/roundToNearestMultipleOfFour'; +import variables from '@styles/variables'; +import type {ComputeHorizontalShift} from './types'; + +/** This defines the proximity with the edge of the window in which tooltips should not be displayed. + * If a tooltip is too close to the edge of the screen, we'll shift it towards the center. */ +const GUTTER_WIDTH = variables.gutterWidth; + +/** + * Compute the amount the tooltip needs to be horizontally shifted in order to keep it from displaying in the gutters. + * + * @param windowWidth - The width of the window. + * @param xOffset - The distance between the left edge of the window + * and the left edge of the wrapped component. + * @param componentWidth - The width of the wrapped component. + * @param tooltipWidth - The width of the tooltip itself. + * @param [manualShiftHorizontal] - Any additional amount to manually shift the tooltip to the left or right. + * A positive value shifts it to the right, + * and a negative value shifts it to the left. + */ +const computeHorizontalShift: ComputeHorizontalShift = (windowWidth, xOffset, componentWidth, tooltipWidth, manualShiftHorizontal) => { + // First find the left and right edges of the tooltip (by default, it is centered on the component). + const componentCenter = xOffset + componentWidth / 2 + manualShiftHorizontal; + const tooltipLeftEdge = componentCenter - tooltipWidth / 2; + const tooltipRightEdge = componentCenter + tooltipWidth / 2; + + if (tooltipLeftEdge < GUTTER_WIDTH) { + // Tooltip is in left gutter, shift right by a multiple of four. + return roundToNearestMultipleOfFour(GUTTER_WIDTH - tooltipLeftEdge); + } + + if (tooltipRightEdge > windowWidth - GUTTER_WIDTH) { + // Tooltip is in right gutter, shift left by a multiple of four. + return roundToNearestMultipleOfFour(windowWidth - GUTTER_WIDTH - tooltipRightEdge); + } + + // Tooltip is not in the gutter, so no need to shift it horizontally + return 0; +}; + +export {GUTTER_WIDTH}; +export default computeHorizontalShift; diff --git a/src/styles/utils/generators/TooltipStyleUtils/computeHorizontalShift/types.ts b/src/styles/utils/generators/TooltipStyleUtils/computeHorizontalShift/types.ts new file mode 100644 index 000000000000..bc82a9b4fbe4 --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/computeHorizontalShift/types.ts @@ -0,0 +1,4 @@ +type ComputeHorizontalShift = (windowWidth: number, xOffset: number, componentWidth: number, tooltipWidth: number, manualShiftHorizontal: number) => number; + +// eslint-disable-next-line import/prefer-default-export +export type {ComputeHorizontalShift}; diff --git a/src/styles/utils/generators/TooltipStyleUtils/index.native.ts b/src/styles/utils/generators/TooltipStyleUtils/index.native.ts deleted file mode 100644 index 8ec3e1ded500..000000000000 --- a/src/styles/utils/generators/TooltipStyleUtils/index.native.ts +++ /dev/null @@ -1,179 +0,0 @@ -import {Animated, StyleSheet} from 'react-native'; -import FontUtils from '@styles/utils/FontUtils'; -// eslint-disable-next-line no-restricted-imports -import type StyleUtilGenerator from '@styles/utils/generators/types'; -// eslint-disable-next-line no-restricted-imports -import positioning from '@styles/utils/positioning'; -// eslint-disable-next-line no-restricted-imports -import spacing from '@styles/utils/spacing'; -import variables from '@styles/variables'; -import type {GetTooltipStylesStyleUtil} from './types'; - -/** The height of a tooltip pointer */ -const POINTER_HEIGHT = 4; - -/** The width of a tooltip pointer */ -const POINTER_WIDTH = 12; - -/** - * Generate styles for the tooltip component. - * - * @param tooltip - The reference to the tooltip's root element - * @param currentSize - The current size of the tooltip used in the scaling animation. - * @param windowWidth - The width of the window. - * @param xOffset - The distance between the left edge of the wrapped component - * and the left edge of the parent component. - * @param yOffset - The distance between the top edge of the wrapped component - * and the top edge of the parent component. - * @param tooltipTargetWidth - The width of the tooltip's target - * @param tooltipTargetHeight - The height of the tooltip's target - * @param maxWidth - The tooltip's max width. - * @param tooltipContentWidth - The tooltip's inner content measured width. - * @param tooltipWrapperHeight - The tooltip's wrapper measured height. - * @param [manualShiftHorizontal] - Any additional amount to manually shift the tooltip to the left or right. - * A positive value shifts it to the right, - * and a negative value shifts it to the left. - * @param [manualShiftVertical] - Any additional amount to manually shift the tooltip up or down. - * A positive value shifts it down, and a negative value shifts it up. - * @param [shouldForceRenderingBelow] - Should display tooltip below the wrapped component. - * @param [shouldForceRenderingLeft] - Align the tooltip left relative to the wrapped component instead of horizontally align center. - * @param [wrapperStyle] - Any additional styles for the root wrapper. - */ -const createTooltipStyleUtils: StyleUtilGenerator = ({theme, styles}) => ({ - getTooltipStyles: ({ - currentSize, - xOffset, - yOffset, - tooltipTargetWidth, - maxWidth, - tooltipContentWidth, - tooltipWrapperHeight, - manualShiftHorizontal = 0, - manualShiftVertical = 0, - shouldForceRenderingLeft = false, - wrapperStyle = {}, - }) => { - const customWrapperStyle = StyleSheet.flatten(wrapperStyle); - const tooltipVerticalPadding = spacing.pv1; - - // We calculate tooltip width based on the tooltip's content width - // so the tooltip wrapper is just big enough to fit content and prevent white space. - // NOTE: Add 1 to the tooltipWidth to prevent truncated text in Safari - const tooltipWidth = tooltipContentWidth && tooltipContentWidth + spacing.ph2.paddingHorizontal * 2 + 1; - const tooltipHeight = tooltipWrapperHeight; - - const isTooltipSizeReady = tooltipWidth !== undefined && tooltipHeight !== undefined; - - // Set the scale to 1 to be able to measure the tooltip size correctly when it's not ready yet. - let scale = new Animated.Value(1); - let rootWrapperTop = 0; - let rootWrapperLeft = 0; - let pointerWrapperTop = 0; - let pointerWrapperLeft = 0; - let opacity = 0; - - if (isTooltipSizeReady) { - // When the tooltip size is ready, we can start animating the scale. - scale = currentSize; - - // Because it uses absolute positioning, the top-left corner of the tooltip is aligned - // with the top-left corner of the wrapped component by default. - // we will use yOffset to position the tooltip relative to the Wrapped Component - // So we need to shift the tooltip vertically and horizontally to position it correctly. - // - // First, we'll position it vertically. - // To shift the tooltip down, we'll give `top` a positive value. - // To shift the tooltip up, we'll give `top` a negative value. - rootWrapperTop = yOffset - (tooltipHeight + POINTER_HEIGHT) + manualShiftVertical; - - // Next, we'll position it horizontally. - // we will use xOffset to position the tooltip relative to the Wrapped Component - // To shift the tooltip right, we'll give `left` a positive value. - // To shift the tooltip left, we'll give `left` a negative value. - // - // So we'll: - // 1a) Horizontally align left: No need for shifting. - // 1b) Horizontally align center: - // - Shift the tooltip right (+) to the center of the component, - // so the left edge lines up with the component center. - // - Shift it left (-) to by half the tooltip's width, - // so the tooltip's center lines up with the center of the wrapped component. - // 2) Add the manual horizontal shift passed in as a parameter. - rootWrapperLeft = xOffset + (shouldForceRenderingLeft ? 0 : tooltipTargetWidth / 2 - tooltipWidth / 2) + manualShiftHorizontal; - - // By default, the pointer's top-left will align with the top-left of the tooltip wrapper. - // - // To align it vertically, the pointer up (-) by the pointer's height - // so that the bottom of the pointer lines up with the top of the tooltip - pointerWrapperTop = tooltipHeight; - - // To align it horizontally, we'll: - // 1) Left align: Shift the pointer to the right (+) by half the pointer's width, - // so the left edge of the pointer does not overlap with the wrapper's border radius. - // 2) Center align: - // - Shift the pointer to the right (+) by the half the tooltipWidth's width, - // so the left edge of the pointer lines up with the tooltipWidth's center. - // - To the left (-) by half the pointer's width, - // so the pointer's center lines up with the tooltipWidth's center. - pointerWrapperLeft = shouldForceRenderingLeft ? POINTER_WIDTH / 2 : tooltipWidth / 2 - POINTER_WIDTH / 2; - - // React Native's measure() is asynchronous, we temporarily hide the tooltip until its bound is calculated - opacity = 100; - } - - return { - animationStyle: { - // remember Transform causes a new Local cordinate system - // https://drafts.csswg.org/css-transforms-1/#transform-rendering - // so Position fixed children will be relative to this new Local cordinate system - transform: [{scale}], - }, - rootWrapperStyle: { - ...positioning.pAbsolute, - backgroundColor: theme.heading, - borderRadius: variables.componentBorderRadiusSmall, - ...tooltipVerticalPadding, - ...spacing.ph2, - zIndex: variables.tooltipzIndex, - width: tooltipWidth, - maxWidth, - top: rootWrapperTop, - left: rootWrapperLeft, - opacity, - ...customWrapperStyle, - - // We are adding this to prevent the tooltip text from being selected and copied on CTRL + A. - ...styles.userSelectNone, - ...styles.pointerEventsNone, - }, - textStyle: { - color: theme.textReversed, - ...FontUtils.fontFamily.platform.EXP_NEUE, - fontSize: variables.fontSizeSmall, - overflow: 'hidden', - lineHeight: variables.lineHeightSmall, - textAlign: 'center', - }, - pointerWrapperStyle: { - ...positioning.pAbsolute, - top: pointerWrapperTop, - left: pointerWrapperLeft, - opacity, - }, - pointerStyle: { - width: 0, - height: 0, - backgroundColor: theme.transparent, - borderStyle: 'solid', - borderLeftWidth: POINTER_WIDTH / 2, - borderRightWidth: POINTER_WIDTH / 2, - borderTopWidth: POINTER_HEIGHT, - borderLeftColor: theme.transparent, - borderRightColor: theme.transparent, - borderTopColor: customWrapperStyle.backgroundColor ?? theme.heading, - }, - }; - }, -}); - -export default createTooltipStyleUtils; diff --git a/src/styles/utils/generators/TooltipStyleUtils/index.ts b/src/styles/utils/generators/TooltipStyleUtils/index.ts index 4dfd0409a93e..07dba25844ea 100644 --- a/src/styles/utils/generators/TooltipStyleUtils/index.ts +++ b/src/styles/utils/generators/TooltipStyleUtils/index.ts @@ -1,21 +1,18 @@ -import type {View} from 'react-native'; +import type {StyleProp, TextStyle, View, ViewStyle} from 'react-native'; import {Animated, StyleSheet} from 'react-native'; -import roundToNearestMultipleOfFour from '@libs/roundToNearestMultipleOfFour'; import FontUtils from '@styles/utils/FontUtils'; // eslint-disable-next-line no-restricted-imports import type StyleUtilGenerator from '@styles/utils/generators/types'; // eslint-disable-next-line no-restricted-imports -import positioning from '@styles/utils/positioning'; -// eslint-disable-next-line no-restricted-imports import spacing from '@styles/utils/spacing'; // eslint-disable-next-line no-restricted-imports import titleBarHeight from '@styles/utils/titleBarHeight'; import variables from '@styles/variables'; -import type {GetTooltipStylesStyleUtil} from './types'; - -/** This defines the proximity with the edge of the window in which tooltips should not be displayed. - * If a tooltip is too close to the edge of the screen, we'll shift it towards the center. */ -const GUTTER_WIDTH = variables.gutterWidth; +import CONST from '@src/CONST'; +import type {TooltipAnchorAlignment} from '@src/types/utils/AnchorAlignment'; +import computeHorizontalShift, {GUTTER_WIDTH} from './computeHorizontalShift'; +import isOverlappingAtTop from './isOverlappingAtTop'; +import tooltipPlatformStyle from './tooltipPlatformStyles'; /** The height of a tooltip pointer */ const POINTER_HEIGHT = 4; @@ -23,81 +20,34 @@ const POINTER_HEIGHT = 4; /** The width of a tooltip pointer */ const POINTER_WIDTH = 12; -/** - * Compute the amount the tooltip needs to be horizontally shifted in order to keep it from displaying in the gutters. - * - * @param windowWidth - The width of the window. - * @param xOffset - The distance between the left edge of the window - * and the left edge of the wrapped component. - * @param componentWidth - The width of the wrapped component. - * @param tooltipWidth - The width of the tooltip itself. - * @param [manualShiftHorizontal] - Any additional amount to manually shift the tooltip to the left or right. - * A positive value shifts it to the right, - * and a negative value shifts it to the left. - */ -function computeHorizontalShift(windowWidth: number, xOffset: number, componentWidth: number, tooltipWidth: number, manualShiftHorizontal: number): number { - // First find the left and right edges of the tooltip (by default, it is centered on the component). - const componentCenter = xOffset + componentWidth / 2 + manualShiftHorizontal; - const tooltipLeftEdge = componentCenter - tooltipWidth / 2; - const tooltipRightEdge = componentCenter + tooltipWidth / 2; - - if (tooltipLeftEdge < GUTTER_WIDTH) { - // Tooltip is in left gutter, shift right by a multiple of four. - return roundToNearestMultipleOfFour(GUTTER_WIDTH - tooltipLeftEdge); - } +type TooltipStyles = { + animationStyle: ViewStyle; + rootWrapperStyle: ViewStyle; + textStyle: TextStyle; + pointerWrapperStyle: ViewStyle; + pointerStyle: ViewStyle; +}; - if (tooltipRightEdge > windowWidth - GUTTER_WIDTH) { - // Tooltip is in right gutter, shift left by a multiple of four. - return roundToNearestMultipleOfFour(windowWidth - GUTTER_WIDTH - tooltipRightEdge); - } +type TooltipParams = { + tooltip: View | HTMLDivElement | null; + currentSize: Animated.Value; + windowWidth: number; + xOffset: number; + yOffset: number; + tooltipTargetWidth: number; + tooltipTargetHeight: number; + maxWidth: number; + tooltipContentWidth?: number; + tooltipWrapperHeight?: number; + manualShiftHorizontal?: number; + manualShiftVertical?: number; + shouldForceRenderingBelow?: boolean; + wrapperStyle: StyleProp; + anchorAlignment?: TooltipAnchorAlignment; + shouldAddHorizontalPadding?: boolean; +}; - // Tooltip is not in the gutter, so no need to shift it horizontally - return 0; -} - -/** - * Determines if there is an overlapping element at the top of a given coordinate. - * (targetCenterX, y) - * | - * v - * _ _ _ _ _ - * | | - * | | - * | | - * | | - * |_ _ _ _ _| - * - * @param tooltip - The reference to the tooltip's root element - * @param xOffset - The distance between the left edge of the window - * and the left edge of the wrapped component. - * @param yOffset - The distance between the top edge of the window - * and the top edge of the wrapped component. - * @param tooltipTargetWidth - The width of the tooltip's target - * @param tooltipTargetHeight - The height of the tooltip's target - */ -function isOverlappingAtTop(tooltip: View | HTMLDivElement, xOffset: number, yOffset: number, tooltipTargetWidth: number, tooltipTargetHeight: number) { - if (typeof document.elementFromPoint !== 'function') { - return false; - } - - // Use the x center position of the target to prevent wrong element returned by elementFromPoint - // in case the target has a border radius or is a multiline text. - const targetCenterX = xOffset + tooltipTargetWidth / 2; - const elementAtTargetCenterX = document.elementFromPoint(targetCenterX, yOffset); - - // Ensure it's not the already rendered element of this very tooltip, so the tooltip doesn't try to "avoid" itself - if (!elementAtTargetCenterX || ('contains' in tooltip && tooltip.contains(elementAtTargetCenterX))) { - return false; - } - - const rectAtTargetCenterX = elementAtTargetCenterX.getBoundingClientRect(); - - // Ensure it's not overlapping with another element by checking if the yOffset is greater than the top of the element - // and less than the bottom of the element. Also ensure the tooltip target is not completely inside the elementAtTargetCenterX by vertical direction - const isOverlappingAtTargetCenterX = yOffset > rectAtTargetCenterX.top && yOffset < rectAtTargetCenterX.bottom && yOffset + tooltipTargetHeight > rectAtTargetCenterX.bottom; - - return isOverlappingAtTargetCenterX; -} +type GetTooltipStylesStyleUtil = {getTooltipStyles: (props: TooltipParams) => TooltipStyles}; /** * Generate styles for the tooltip component. @@ -120,7 +70,7 @@ function isOverlappingAtTop(tooltip: View | HTMLDivElement, xOffset: number, yOf * @param [manualShiftVertical] - Any additional amount to manually shift the tooltip up or down. * A positive value shifts it down, and a negative value shifts it up. * @param [shouldForceRenderingBelow] - Should display tooltip below the wrapped component. - * @param [shouldForceRenderingLeft] - Align the tooltip left relative to the wrapped component instead of horizontally align center. + * @param [anchorAlignment] - Align tooltip anchor horizontally and vertically. * @param [wrapperStyle] - Any additional styles for the root wrapper. */ const createTooltipStyleUtils: StyleUtilGenerator = ({theme, styles}) => ({ @@ -137,17 +87,22 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( tooltipWrapperHeight, manualShiftHorizontal = 0, manualShiftVertical = 0, + shouldAddHorizontalPadding = true, shouldForceRenderingBelow = false, - shouldForceRenderingLeft = false, + anchorAlignment = { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }, wrapperStyle = {}, }) => { const customWrapperStyle = StyleSheet.flatten(wrapperStyle); const tooltipVerticalPadding = spacing.pv1; + const tooltipHorizontalPadding = shouldAddHorizontalPadding ? spacing.ph2.paddingHorizontal * 2 : 0; // We calculate tooltip width based on the tooltip's content width // so the tooltip wrapper is just big enough to fit content and prevent white space. // NOTE: Add 1 to the tooltipWidth to prevent truncated text in Safari - const tooltipWidth = tooltipContentWidth && tooltipContentWidth + spacing.ph2.paddingHorizontal * 2 + 1; + const tooltipWidth = tooltipContentWidth && tooltipContentWidth + tooltipHorizontalPadding + 1; const tooltipHeight = tooltipWrapperHeight; const isTooltipSizeReady = tooltipWidth !== undefined && tooltipHeight !== undefined; @@ -162,6 +117,7 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( let pointerWrapperTop = 0; let pointerWrapperLeft = 0; let pointerAdditionalStyle = {}; + let opacity = 0; if (isTooltipSizeReady) { // Determine if the tooltip should display below the wrapped component. @@ -171,7 +127,8 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( shouldShowBelow = shouldForceRenderingBelow || yOffset - tooltipHeight - POINTER_HEIGHT < GUTTER_WIDTH + titleBarHeight || - !!(tooltip && isOverlappingAtTop(tooltip, xOffset, yOffset, tooltipTargetWidth, tooltipTargetHeight)); + !!(tooltip && isOverlappingAtTop(tooltip, xOffset, yOffset, tooltipTargetWidth, tooltipTargetHeight)) || + anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP; // When the tooltip size is ready, we can start animating the scale. scale = currentSize; @@ -202,22 +159,6 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( : // We need to shift the tooltip up above the component. So shift the tooltip up (-) by... yOffset - (tooltipHeight + POINTER_HEIGHT) + manualShiftVertical; - // Next, we'll position it horizontally. - // we will use xOffset to position the tooltip relative to the Wrapped Component - // To shift the tooltip right, we'll give `left` a positive value. - // To shift the tooltip left, we'll give `left` a negative value. - // - // So we'll: - // 1a) Horizontally align left: No need for shifting. - // 1b) Horizontally align center: - // - Shift the tooltip right (+) to the center of the component, - // so the left edge lines up with the component center. - // - Shift it left (-) to by half the tooltip's width, - // so the tooltip's center lines up with the center of the wrapped component. - // 2) Add the horizontal shift (left or right) computed above to keep it out of the gutters. - // 3) Lastly, add the manual horizontal shift passed in as a parameter. - rootWrapperLeft = xOffset + (shouldForceRenderingLeft ? 0 : tooltipTargetWidth / 2 - tooltipWidth / 2) + horizontalShift + manualShiftHorizontal; - // By default, the pointer's top-left will align with the top-left of the tooltip wrapper. // // To align it vertically, we'll: @@ -228,6 +169,22 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( // so that the bottom of the pointer lines up with the top of the tooltip pointerWrapperTop = shouldShowBelow ? -POINTER_HEIGHT : tooltipHeight; + // Horizontal tooltip position: + // we will use xOffset to position the tooltip relative to the Wrapped Component + // To shift the tooltip right, we'll give `left` a positive value. + // To shift the tooltip left, we'll give `left` a negative value. + // + // So we'll: + // 1) Add the horizontal shift (left or right) computed above to keep it out of the gutters. + // 2) Add the manual horizontal shift passed in as a parameter. + // 3a) Horizontally align left: No need for shifting. + // 3b) Horizontally align center: + // - Shift the tooltip right (+) to the center of the component, + // so the left edge lines up with the component center. + // - Shift it left (-) to by half the tooltip's width, + // so the tooltip's center lines up with the center of the wrapped component. + + // Horizontal pointer position: // 1) Left align: Shift the pointer to the right (+) by half the pointer's width, // so the left edge of the pointer does not overlap with the wrapper's border radius. // 2) Center align: @@ -237,9 +194,25 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( // so the pointer's center lines up with the tooltipWidth's center. // - Remove the wrapper's horizontalShift to maintain the pointer // at the center of the hovered component. - pointerWrapperLeft = shouldForceRenderingLeft ? POINTER_WIDTH / 2 : horizontalShiftPointer + (tooltipWidth / 2 - POINTER_WIDTH / 2); + rootWrapperLeft = xOffset + horizontalShift + manualShiftHorizontal; + switch (anchorAlignment.horizontal) { + case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT: + pointerWrapperLeft = POINTER_WIDTH / 2; + break; + case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT: + pointerWrapperLeft = horizontalShiftPointer + (tooltipWidth - POINTER_WIDTH * 1.5); + rootWrapperLeft += tooltipTargetWidth - tooltipWidth; + break; + case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER: + default: + pointerWrapperLeft = horizontalShiftPointer + (tooltipWidth / 2 - POINTER_WIDTH / 2); + rootWrapperLeft += tooltipTargetWidth / 2 - tooltipWidth / 2; + } pointerAdditionalStyle = shouldShowBelow ? styles.flipUpsideDown : {}; + + // React Native's measure() is asynchronous, we temporarily hide the tooltip until its bound is calculated + opacity = 100; } return { @@ -250,7 +223,7 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( transform: [{scale}], }, rootWrapperStyle: { - ...positioning.pFixed, + ...tooltipPlatformStyle, backgroundColor: theme.heading, borderRadius: variables.componentBorderRadiusSmall, ...tooltipVerticalPadding, @@ -260,6 +233,7 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( maxWidth, top: rootWrapperTop, left: rootWrapperLeft, + opacity, ...customWrapperStyle, // We are adding this to prevent the tooltip text from being selected and copied on CTRL + A. @@ -275,9 +249,10 @@ const createTooltipStyleUtils: StyleUtilGenerator = ( textAlign: 'center', }, pointerWrapperStyle: { - ...positioning.pFixed, + ...tooltipPlatformStyle, top: pointerWrapperTop, left: pointerWrapperLeft, + opacity, }, pointerStyle: { width: 0, diff --git a/src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/index.native.ts b/src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/index.native.ts new file mode 100644 index 000000000000..fa80f4471870 --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/index.native.ts @@ -0,0 +1,5 @@ +import type IsOverlappingAtTop from './types'; + +const isOverlappingAtTop: IsOverlappingAtTop = () => false; + +export default isOverlappingAtTop; diff --git a/src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/index.ts b/src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/index.ts new file mode 100644 index 000000000000..081d1a0a693e --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/index.ts @@ -0,0 +1,47 @@ +import type IsOverlappingAtTop from './types'; + +/** + * Determines if there is an overlapping element at the top of a given coordinate. + * (targetCenterX, y) + * | + * v + * _ _ _ _ _ + * | | + * | | + * | | + * | | + * |_ _ _ _ _| + * + * @param tooltip - The reference to the tooltip's root element + * @param xOffset - The distance between the left edge of the window + * and the left edge of the wrapped component. + * @param yOffset - The distance between the top edge of the window + * and the top edge of the wrapped component. + * @param tooltipTargetWidth - The width of the tooltip's target + * @param tooltipTargetHeight - The height of the tooltip's target + */ +const isOverlappingAtTop: IsOverlappingAtTop = (tooltip, xOffset, yOffset, tooltipTargetWidth, tooltipTargetHeight) => { + if (typeof document.elementFromPoint !== 'function') { + return false; + } + + // Use the x center position of the target to prevent wrong element returned by elementFromPoint + // in case the target has a border radius or is a multiline text. + const targetCenterX = xOffset + tooltipTargetWidth / 2; + const elementAtTargetCenterX = document.elementFromPoint(targetCenterX, yOffset); + + // Ensure it's not the already rendered element of this very tooltip, so the tooltip doesn't try to "avoid" itself + if (!elementAtTargetCenterX || ('contains' in tooltip && tooltip.contains(elementAtTargetCenterX))) { + return false; + } + + const rectAtTargetCenterX = elementAtTargetCenterX.getBoundingClientRect(); + + // Ensure it's not overlapping with another element by checking if the yOffset is greater than the top of the element + // and less than the bottom of the element. Also ensure the tooltip target is not completely inside the elementAtTargetCenterX by vertical direction + const isOverlappingAtTargetCenterX = yOffset > rectAtTargetCenterX.top && yOffset < rectAtTargetCenterX.bottom && yOffset + tooltipTargetHeight > rectAtTargetCenterX.bottom; + + return isOverlappingAtTargetCenterX; +}; + +export default isOverlappingAtTop; diff --git a/src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/types.ts b/src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/types.ts new file mode 100644 index 000000000000..bdd8ff346a86 --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/isOverlappingAtTop/types.ts @@ -0,0 +1,5 @@ +import type {View} from 'react-native'; + +type IsOverlappingAtTop = (tooltip: View | HTMLDivElement, xOffset: number, yOffset: number, tooltipTargetWidth: number, tooltipTargetHeight: number) => boolean; + +export default IsOverlappingAtTop; diff --git a/src/styles/utils/generators/TooltipStyleUtils/tooltipPlatformStyles/index.native.ts b/src/styles/utils/generators/TooltipStyleUtils/tooltipPlatformStyles/index.native.ts new file mode 100644 index 000000000000..17cc7200b20d --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/tooltipPlatformStyles/index.native.ts @@ -0,0 +1,7 @@ +import type {ViewStyle} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import positioning from '@styles/utils/positioning'; + +const tooltipPlatformStyle: ViewStyle = positioning.pAbsolute; + +export default tooltipPlatformStyle; diff --git a/src/styles/utils/generators/TooltipStyleUtils/tooltipPlatformStyles/index.ts b/src/styles/utils/generators/TooltipStyleUtils/tooltipPlatformStyles/index.ts new file mode 100644 index 000000000000..fd49d03b9413 --- /dev/null +++ b/src/styles/utils/generators/TooltipStyleUtils/tooltipPlatformStyles/index.ts @@ -0,0 +1,7 @@ +import type {ViewStyle} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import positioning from '@styles/utils/positioning'; + +const tooltipPlatformStyle: ViewStyle = positioning.pFixed; + +export default tooltipPlatformStyle; diff --git a/src/styles/utils/generators/TooltipStyleUtils/types.ts b/src/styles/utils/generators/TooltipStyleUtils/types.ts deleted file mode 100644 index 1907309e1bf5..000000000000 --- a/src/styles/utils/generators/TooltipStyleUtils/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type {Animated, StyleProp, TextStyle, View, ViewStyle} from 'react-native'; - -type TooltipStyles = { - animationStyle: ViewStyle; - rootWrapperStyle: ViewStyle; - textStyle: TextStyle; - pointerWrapperStyle: ViewStyle; - pointerStyle: ViewStyle; -}; - -type TooltipParams = { - tooltip: View | HTMLDivElement | null; - currentSize: Animated.Value; - windowWidth: number; - xOffset: number; - yOffset: number; - tooltipTargetWidth: number; - tooltipTargetHeight: number; - maxWidth: number; - tooltipContentWidth?: number; - tooltipWrapperHeight?: number; - manualShiftHorizontal?: number; - manualShiftVertical?: number; - shouldForceRenderingBelow?: boolean; - shouldForceRenderingLeft?: boolean; - wrapperStyle: StyleProp; -}; - -type GetTooltipStylesStyleUtil = {getTooltipStyles: (props: TooltipParams) => TooltipStyles}; - -export type {TooltipStyles, TooltipParams, GetTooltipStylesStyleUtil}; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index e0720ad1d836..ea838feee1a0 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -246,6 +246,9 @@ export default { searchTypeColumnWidth: 52, + composerTooltipShiftHorizontal: 10, + composerTooltipShiftVertical: -10, + h20: 20, h28: 28, h36: 36, diff --git a/src/types/onyx/WorkspaceTooltip.ts b/src/types/onyx/WorkspaceTooltip.ts new file mode 100644 index 000000000000..4371ac6533d8 --- /dev/null +++ b/src/types/onyx/WorkspaceTooltip.ts @@ -0,0 +1,9 @@ +/** + * The NVP containing all information related to educational tooltip in workspace chat. + */ +type WorkspaceTooltip = { + /** Should show educational tooltip in workspace chat for first-time user */ + shouldShow: boolean; +}; + +export default WorkspaceTooltip; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index a2a66e89003f..0bbff2e5f7f6 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -99,6 +99,7 @@ import type WalletStatement from './WalletStatement'; import type WalletTerms from './WalletTerms'; import type WalletTransfer from './WalletTransfer'; import type WorkspaceRateAndUnit from './WorkspaceRateAndUnit'; +import type WorkspaceTooltip from './WorkspaceTooltip'; export type { TryNewDot, @@ -218,4 +219,5 @@ export type { CancellationDetails, ApprovalWorkflow, MobileSelectionMode, + WorkspaceTooltip, }; diff --git a/src/types/utils/AnchorAlignment.ts b/src/types/utils/AnchorAlignment.ts index 899e3d9e277b..5ed043d36b18 100644 --- a/src/types/utils/AnchorAlignment.ts +++ b/src/types/utils/AnchorAlignment.ts @@ -9,4 +9,13 @@ type AnchorAlignment = { vertical: ValueOf; }; +type TooltipAnchorAlignment = { + /** The horizontal anchor alignment of the tooltip */ + horizontal: ValueOf; + + /** The vertical anchor alignment of the tooltip */ + vertical: Exclude, typeof CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.CENTER>; +}; + +export type {TooltipAnchorAlignment}; export default AnchorAlignment;