diff --git a/native/components/nux-handler.react.js b/native/components/nux-handler.react.js index ba4b192d8..2a14ab866 100644 --- a/native/components/nux-handler.react.js +++ b/native/components/nux-handler.react.js @@ -1,52 +1,39 @@ // @flow import { useNavigation } from '@react-navigation/core'; import invariant from 'invariant'; import * as React from 'react'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; -import { - firstNUXTipKey, - NUXTipsContext, - getNUXTipParams, -} from './nux-tips-context.react.js'; -import type { NUXTipRouteNames } from '../navigation/route-names.js'; +import { NUXTipsContext } from './nux-tips-context.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnFirstLaunchEffect } from '../utils/hooks.js'; function NUXHandler(): React.Node { const nuxTipsContext = React.useContext(NUXTipsContext); invariant(nuxTipsContext, 'nuxTipsContext should be defined'); const { tipsProps } = nuxTipsContext; const loggedIn = useSelector(isLoggedIn); if (!tipsProps || !loggedIn) { return null; } return ; } function NUXHandlerInner(): React.Node { const navigation = useNavigation(); const effect = React.useCallback(() => { - const { nextTip, tooltipLocation, nextRouteName } = - getNUXTipParams(firstNUXTipKey); - invariant(nextRouteName && nextTip, 'first nux tip should be defined'); - - navigation.navigate({ - name: nextRouteName, - params: { - tipKey: nextTip, - tooltipLocation, - }, + navigation.navigate<'NUXTipOverlayBackdrop'>({ + name: 'NUXTipOverlayBackdrop', }); }, [navigation]); useOnFirstLaunchEffect('NUX_HANDLER', effect); } export { NUXHandler }; diff --git a/native/components/nux-tips-context.react.js b/native/components/nux-tips-context.react.js index 38e4a270c..2f6acd21b 100644 --- a/native/components/nux-tips-context.react.js +++ b/native/components/nux-tips-context.react.js @@ -1,122 +1,127 @@ // @flow import * as React from 'react'; import { values } from 'lib/utils/objects.js'; +import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { CommunityDrawerTipRouteName, MutedTabTipRouteName, } from '../navigation/route-names.js'; import type { NUXTipRouteNames } from '../navigation/route-names.js'; const nuxTip = Object.freeze({ COMMUNITY_DRAWER: 'community_drawer', MUTED: 'muted', HOME: 'home', }); export type NUXTip = $Values; type NUXTipParams = { +nextTip: ?NUXTip, +tooltipLocation: 'below' | 'above', +nextRouteName: ?NUXTipRouteNames, + +exitingCallback?: ( + navigation: AppNavigationProp, + ) => void, }; const firstNUXTipKey = 'firstTip'; type NUXTipParamsKeys = NUXTip | 'firstTip'; const nuxTipParams: { +[NUXTipParamsKeys]: NUXTipParams } = { [firstNUXTipKey]: { nextTip: nuxTip.COMMUNITY_DRAWER, tooltipLocation: 'below', nextRouteName: CommunityDrawerTipRouteName, }, [nuxTip.COMMUNITY_DRAWER]: { nextTip: nuxTip.MUTED, tooltipLocation: 'below', nextRouteName: MutedTabTipRouteName, }, [nuxTip.MUTED]: { nextTip: undefined, nextRouteName: undefined, tooltipLocation: 'below', + exitingCallback: navigation => navigation.goBack(), }, }; function getNUXTipParams(currentTipKey: NUXTipParamsKeys): NUXTipParams { return nuxTipParams[currentTipKey]; } type TipProps = { +x: number, +y: number, +width: number, +height: number, +pageX: number, +pageY: number, }; export type NUXTipsContextType = { +registerTipButton: (type: NUXTip, tipProps: ?TipProps) => void, +tipsProps: ?{ +[type: NUXTip]: TipProps }, }; const NUXTipsContext: React.Context = React.createContext(); type Props = { +children: React.Node, }; function NUXTipsContextProvider(props: Props): React.Node { const { children } = props; const [tipsPropsState, setTipsPropsState] = React.useState<{ +[tip: NUXTip]: ?TipProps, }>(() => ({})); const registerTipButton = React.useCallback( (type: NUXTip, tipProps: ?TipProps) => { setTipsPropsState(currenttipsPropsState => { const newtipsPropsState = { ...currenttipsPropsState }; newtipsPropsState[type] = tipProps; return newtipsPropsState; }); }, [], ); const tipsProps = React.useMemo(() => { const result: { [tip: NUXTip]: TipProps } = {}; for (const type of values(nuxTip)) { if (!tipsPropsState[type]) { return null; } result[type] = tipsPropsState[type]; } return result; }, [tipsPropsState]); const value = React.useMemo( () => ({ registerTipButton, tipsProps, }), [tipsProps, registerTipButton], ); return ( {children} ); } export { NUXTipsContext, NUXTipsContextProvider, nuxTip, getNUXTipParams, firstNUXTipKey, }; diff --git a/native/navigation/app-navigator.react.js b/native/navigation/app-navigator.react.js index fb2248a39..ef246e3f0 100644 --- a/native/navigation/app-navigator.react.js +++ b/native/navigation/app-navigator.react.js @@ -1,174 +1,180 @@ // @flow import * as SplashScreen from 'expo-splash-screen'; import * as React from 'react'; import { PersistGate } from 'redux-persist/es/integration/react.js'; import ActionResultModal from './action-result-modal.react.js'; import { CommunityDrawerNavigator } from './community-drawer-navigator.react.js'; import CommunityDrawerTip from './community-drawer-tip.react.js'; import MutedTabTip from './muted-tab-tip.react.js'; +import NUXTipOverlayBackdrop from './nux-tip-overlay-backdrop.react.js'; import { createOverlayNavigator } from './overlay-navigator.react.js'; import type { OverlayNavigationProp, OverlayNavigationHelpers, } from './overlay-navigator.react.js'; import type { RootNavigationProp } from './root-navigator.react.js'; import { CommunityDrawerTipRouteName, MutedTabTipRouteName, + NUXTipOverlayBackdropRouteName, } from './route-names.js'; import { UserAvatarCameraModalRouteName, ThreadAvatarCameraModalRouteName, ImageModalRouteName, MultimediaMessageTooltipModalRouteName, ActionResultModalRouteName, TextMessageTooltipModalRouteName, ThreadSettingsMemberTooltipModalRouteName, UserRelationshipTooltipModalRouteName, RobotextMessageTooltipModalRouteName, ChatCameraModalRouteName, VideoPlaybackModalRouteName, CommunityDrawerNavigatorRouteName, type ScreenParamList, type OverlayParamList, TogglePinModalRouteName, } from './route-names.js'; import MultimediaMessageTooltipModal from '../chat/multimedia-message-tooltip-modal.react.js'; import RobotextMessageTooltipModal from '../chat/robotext-message-tooltip-modal.react.js'; import ThreadSettingsMemberTooltipModal from '../chat/settings/thread-settings-member-tooltip-modal.react.js'; import TextMessageTooltipModal from '../chat/text-message-tooltip-modal.react.js'; import TogglePinModal from '../chat/toggle-pin-modal.react.js'; import KeyboardStateContainer from '../keyboard/keyboard-state-container.react.js'; import ChatCameraModal from '../media/chat-camera-modal.react.js'; import ImageModal from '../media/image-modal.react.js'; import ThreadAvatarCameraModal from '../media/thread-avatar-camera-modal.react.js'; import UserAvatarCameraModal from '../media/user-avatar-camera-modal.react.js'; import VideoPlaybackModal from '../media/video-playback-modal.react.js'; import UserRelationshipTooltipModal from '../profile/user-relationship-tooltip-modal.react.js'; import PushHandler from '../push/push-handler.react.js'; import { getPersistor } from '../redux/persist.js'; import { useSelector } from '../redux/redux-utils.js'; import { RootContext } from '../root-context.js'; import { useLoadCommFonts } from '../themes/fonts.js'; import { waitForInteractions } from '../utils/timers.js'; let splashScreenHasHidden = false; export type AppNavigationProp< RouteName: $Keys = $Keys, > = OverlayNavigationProp; const App = createOverlayNavigator< ScreenParamList, OverlayParamList, OverlayNavigationHelpers, >(); type AppNavigatorProps = { navigation: RootNavigationProp<'App'>, ... }; function AppNavigator(props: AppNavigatorProps): React.Node { const { navigation } = props; const fontsLoaded = useLoadCommFonts(); const rootContext = React.useContext(RootContext); const storeLoadedFromLocalDatabase = useSelector(state => state.storeLoaded); const setNavStateInitialized = rootContext && rootContext.setNavStateInitialized; React.useEffect(() => { setNavStateInitialized && setNavStateInitialized(); }, [setNavStateInitialized]); const [localSplashScreenHasHidden, setLocalSplashScreenHasHidden] = React.useState(splashScreenHasHidden); React.useEffect(() => { if (localSplashScreenHasHidden || !fontsLoaded) { return; } splashScreenHasHidden = true; void (async () => { await waitForInteractions(); try { await SplashScreen.hideAsync(); } finally { setLocalSplashScreenHasHidden(true); } })(); }, [localSplashScreenHasHidden, fontsLoaded]); let pushHandler; if (localSplashScreenHasHidden) { pushHandler = ( ); } if (!storeLoadedFromLocalDatabase) { return null; } return ( + {pushHandler} ); } export default AppNavigator; diff --git a/native/navigation/nux-tip-overlay-backdrop.react.js b/native/navigation/nux-tip-overlay-backdrop.react.js new file mode 100644 index 000000000..b98da3193 --- /dev/null +++ b/native/navigation/nux-tip-overlay-backdrop.react.js @@ -0,0 +1,105 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; +import { withTiming } from 'react-native-reanimated'; + +import type { AppNavigationProp } from './app-navigator.react.js'; +import { OverlayContext } from './overlay-context.js'; +import type { NUXTipRouteNames, NavigationRoute } from './route-names'; +import { + firstNUXTipKey, + getNUXTipParams, +} from '../components/nux-tips-context.react.js'; +import { useStyles } from '../themes/colors.js'; +import { animationDuration } from '../tooltip/nux-tips-overlay.react.js'; +import { AnimatedView } from '../types/styles.js'; + +type Props = { + +navigation: AppNavigationProp<'NUXTipOverlayBackdrop'>, + +route: NavigationRoute<'NUXTipOverlayBackdrop'>, +}; + +function NUXTipOverlayBackdrop(props: Props): React.Node { + const overlayContext = React.useContext(OverlayContext); + invariant(overlayContext, 'NUXTipsOverlay should have OverlayContext'); + const { shouldRenderScreenContent } = overlayContext; + + return shouldRenderScreenContent ? ( + + ) : null; +} + +function opacityEnteringAnimation() { + 'worklet'; + + return { + animations: { + opacity: withTiming(0.7, { duration: animationDuration }), + }, + initialValues: { + opacity: 0, + }, + }; +} + +function NUXTipOverlayBackdropInner(props: Props): React.Node { + const overlayContext = React.useContext(OverlayContext); + invariant(overlayContext, 'NUXTipsOverlay should have OverlayContext'); + const { onExitFinish } = overlayContext; + + const styles = useStyles(unboundStyles); + + const opacityExitingAnimation = React.useCallback(() => { + 'worklet'; + + return { + animations: { + opacity: withTiming(0, { duration: animationDuration }), + }, + initialValues: { + opacity: 0.7, + }, + callback: onExitFinish, + }; + }, [onExitFinish]); + + const { nextTip, tooltipLocation, nextRouteName } = + getNUXTipParams(firstNUXTipKey); + invariant(nextRouteName && nextTip, 'first nux tip should be defined'); + + React.useEffect( + () => + props.navigation.navigate({ + name: nextRouteName, + params: { + tipKey: nextTip, + tooltipLocation, + }, + }), + // We want this effect to run exactly once, when this component is mounted + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + return ( + + ); +} + +const unboundStyles = { + backdrop: { + backgroundColor: 'black', + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, +}; + +export default NUXTipOverlayBackdrop; diff --git a/native/navigation/overlay-navigator.react.js b/native/navigation/overlay-navigator.react.js index e602c5514..2b1928817 100644 --- a/native/navigation/overlay-navigator.react.js +++ b/native/navigation/overlay-navigator.react.js @@ -1,575 +1,577 @@ // @flow import type { StackNavigationState, NavigatorPropsBase, ExtraNavigatorPropsBase, CreateNavigator, StackNavigationProp, ParamListBase, StackNavigationHelpers, ScreenListeners, StackRouterOptions, Descriptor, Route, } from '@react-navigation/core'; import { useNavigationBuilder, createNavigatorFactory, NavigationHelpersContext, } from '@react-navigation/native'; import { TransitionPresets } from '@react-navigation/stack'; import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import Animated, { EasingNode } from 'react-native-reanimated'; import { values } from 'lib/utils/objects.js'; import { OverlayContext, type VisibleOverlay, type ScrollBlockingModalStatus, } from './overlay-context.js'; import OverlayRouter from './overlay-router.js'; import type { OverlayRouterExtraNavigationHelpers, OverlayRouterNavigationAction, } from './overlay-router.js'; import { scrollBlockingModals, TabNavigatorRouteName, CommunityDrawerTipRouteName, MutedTabTipRouteName, + NUXTipOverlayBackdropRouteName, } from './route-names.js'; import { isMessageTooltipKey } from '../chat/utils.js'; const newReanimatedRoutes = new Set([ CommunityDrawerTipRouteName, MutedTabTipRouteName, + NUXTipOverlayBackdropRouteName, ]); export type OverlayNavigationHelpers = { ...$Exact>, ...OverlayRouterExtraNavigationHelpers, ... }; export type OverlayNavigationProp< ParamList: ParamListBase = ParamListBase, RouteName: $Keys = $Keys, > = { ...StackNavigationProp, ...OverlayRouterExtraNavigationHelpers, }; const { Value, timing, cond, call, lessOrEq, block } = Animated; type Scene = { +route: Route<>, +descriptor: Descriptor, {}>, +context: { +position: ?Value, +shouldRenderScreenContent: boolean, +onExitFinish?: () => void, +isDismissing: boolean, }, +ordering: { +routeIndex: number, }, }; type SceneData = $ReadOnly<{ ...Scene, +context: $ReadOnly<{ ...$PropertyType, +visibleOverlays: $ReadOnlyArray, +scrollBlockingModalStatus: ScrollBlockingModalStatus, +setScrollBlockingModalStatus: ScrollBlockingModalStatus => void, +resetScrollBlockingModalStatus: () => void, }>, +ordering: $ReadOnly<{ ...$PropertyType, +creationTime: number, }>, +listeners: $ReadOnlyArray, }>; type Props = $Exact< NavigatorPropsBase< {}, ScreenListeners, OverlayNavigationHelpers<>, >, >; const OverlayNavigator = React.memo( ({ initialRouteName, children, screenOptions, screenListeners }: Props) => { const { state, descriptors, navigation } = useNavigationBuilder< StackNavigationState, OverlayRouterNavigationAction, {}, StackRouterOptions, OverlayNavigationHelpers<>, {}, ExtraNavigatorPropsBase, >(OverlayRouter, { children, screenOptions, screenListeners, initialRouteName, }); const curIndex = state.index; const positionRefs = React.useRef<{ [string]: Animated.Value }>({}); const positions = positionRefs.current; const firstRenderRef = React.useRef(true); React.useEffect(() => { firstRenderRef.current = false; }, [firstRenderRef]); const firstRender = firstRenderRef.current; const { routes } = state; const scenes = React.useMemo( () => routes.map((route, routeIndex) => { const descriptor = descriptors[route.key]; invariant( descriptor, `OverlayNavigator could not find descriptor for ${route.key}`, ); const shouldUseLegacyAnimation = !newReanimatedRoutes.has(route.name); if (!positions[route.key] && shouldUseLegacyAnimation) { positions[route.key] = new Value(firstRender ? 1 : 0); } return { route, descriptor, context: { position: positions[route.key], isDismissing: curIndex < routeIndex, shouldRenderScreenContent: true, }, ordering: { routeIndex, }, }; }), // We don't include descriptors here because they can change on every // render. We know that they should only substantially change if something // about the underlying route has changed // eslint-disable-next-line react-hooks/exhaustive-deps [positions, routes, curIndex], ); const prevScenesRef = React.useRef>(); const prevScenes = prevScenesRef.current; const visibleOverlayEntryForNewScene = (scene: Scene) => { const { route } = scene; if (route.name === TabNavigatorRouteName) { // We don't consider the TabNavigator at the bottom to be an overlay return undefined; } const presentedFrom = typeof route.params?.presentedFrom === 'string' ? route.params.presentedFrom : undefined; return { routeKey: route.key, routeName: route.name, position: positions[route.key], shouldRenderScreenContent: true, presentedFrom, }; }; const visibleOverlaysRef = React.useRef>(); if (!visibleOverlaysRef.current) { visibleOverlaysRef.current = scenes .map(visibleOverlayEntryForNewScene) .filter(Boolean); } let visibleOverlays = visibleOverlaysRef.current; // The scrollBlockingModalStatus state gets incorporated into the // OverlayContext, but it's global to the navigator rather than local to // each screen. Note that we also include the setter in OverlayContext. We // do this so that screens can freeze ScrollViews as quickly as possible to // avoid drags after onLongPress is triggered const getScrollBlockingModalStatus = ( data: $ReadOnlyArray, ) => { let status = 'closed'; for (const scene of data) { if (!scrollBlockingModals.includes(scene.route.name)) { continue; } if (!scene.context.isDismissing) { status = 'open'; break; } status = 'closing'; } return status; }; const [scrollBlockingModalStatus, setScrollBlockingModalStatus] = React.useState(() => getScrollBlockingModalStatus(scenes)); const resetScrollBlockingModalStatus = React.useCallback(() => { setScrollBlockingModalStatus( getScrollBlockingModalStatus(prevScenesRef.current ?? []), ); }, []); const sceneDataForNewScene = (scene: Scene) => ({ ...scene, context: { ...scene.context, visibleOverlays, scrollBlockingModalStatus, setScrollBlockingModalStatus, resetScrollBlockingModalStatus, }, ordering: { ...scene.ordering, creationTime: Date.now(), }, listeners: [], }); // We track two previous states of scrollBlockingModalStatus via refs. We // need two because we expose setScrollBlockingModalStatus to screens. We // track the previous sceneData-determined value separately so that we only // overwrite the screen-determined value with the sceneData-determined value // when the latter actually changes const prevScrollBlockingModalStatusRef = React.useRef( scrollBlockingModalStatus, ); const prevScrollBlockingModalStatus = prevScrollBlockingModalStatusRef.current; const prevScrollBlockingModalStatusFromSceneDataRef = React.useRef( scrollBlockingModalStatus, ); const prevScrollBlockingModalStatusFromSceneData = prevScrollBlockingModalStatusFromSceneDataRef.current; // We need state to continue rendering screens while they are dismissing const [sceneData, setSceneData] = React.useState(() => { const newSceneData: { [string]: SceneData } = {}; for (const scene of scenes) { const { key } = scene.route; newSceneData[key] = sceneDataForNewScene(scene); } return newSceneData; }); const prevSceneDataRef = React.useRef(sceneData); const prevSceneData = prevSceneDataRef.current; // We need to initiate animations in useEffect blocks, but because we // setState within render we might have multiple renders before the // useEffect triggers. So we cache whether or not new animations should be // started in this ref const pendingAnimationsRef = React.useRef<{ [key: string]: number }>({}); const queueAnimation = (key: string, toValue: number) => { pendingAnimationsRef.current = { ...pendingAnimationsRef.current, [key]: toValue, }; }; // This block keeps sceneData updated when our props change. It's the // hook equivalent of getDerivedStateFromProps // https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops const updatedSceneData = { ...sceneData }; let sceneDataChanged = false; if (prevScenes && scenes !== prevScenes) { const currentKeys = new Set(); for (const scene of scenes) { const { key } = scene.route; currentKeys.add(key); let data = updatedSceneData[key]; if (!data) { // A new route has been pushed const newVisibleOverlayEntry = visibleOverlayEntryForNewScene(scene); if (newVisibleOverlayEntry) { visibleOverlays = [...visibleOverlays, newVisibleOverlayEntry]; } updatedSceneData[key] = sceneDataForNewScene(scene); sceneDataChanged = true; queueAnimation(key, 1); continue; } let dataChanged = false; if (scene.route !== data.route) { data = { ...data, route: scene.route }; dataChanged = true; } if (scene.descriptor !== data.descriptor) { data = { ...data, descriptor: scene.descriptor }; // We don't set dataChanged here because descriptors get recomputed on // every render, which means we could get an infinite loop. However, // we want to update the descriptor whenever anything else changes, so // that if and when our scene is dismissed, the sceneData has the most // recent descriptor } if (scene.context.isDismissing !== data.context.isDismissing) { data = { ...data, context: { ...data.context, ...scene.context } }; dataChanged = true; } if (scene.ordering.routeIndex !== data.ordering.routeIndex) { data = { ...data, ordering: { ...data.ordering, ...scene.ordering } }; dataChanged = true; } if (dataChanged) { // Something about an existing route has changed updatedSceneData[key] = data; sceneDataChanged = true; } } for (let i = 0; i < prevScenes.length; i++) { const scene = prevScenes[i]; const { key } = scene.route; if (currentKeys.has(key)) { continue; } currentKeys.add(key); const data = updatedSceneData[key]; invariant(data, `should have sceneData for dismissed key ${key}`); if (!visibleOverlayEntryForNewScene(scene)) { // This should only happen if TabNavigator gets dismissed // TabNavigator doesn't normally ever get dismissed, but hot reload // can cause that to happen. We don't need to animate TabNavigator // closed, and in fact we would crash if we tried. So we short-circuit // the logic below delete updatedSceneData[key]; sceneDataChanged = true; continue; } // A route just got dismissed // We'll watch the animation to determine when to clear the screen const { position } = data.context; const removeScreen = () => { // This gets called when the scene is no longer visible and // handles cleaning up our data structures to remove it const curVisibleOverlays = visibleOverlaysRef.current; invariant(curVisibleOverlays, 'visibleOverlaysRef should be set'); const newVisibleOverlays = curVisibleOverlays.filter( (overlay: VisibleOverlay) => overlay.routeKey !== key, ); if (newVisibleOverlays.length === curVisibleOverlays.length) { return; } visibleOverlaysRef.current = newVisibleOverlays; setSceneData(curSceneData => { const newSceneData: { [string]: SceneData } = {}; for (const sceneKey in curSceneData) { if (sceneKey === key) { continue; } newSceneData[sceneKey] = { ...curSceneData[sceneKey], context: { ...curSceneData[sceneKey].context, visibleOverlays: newVisibleOverlays, }, }; } return newSceneData; }); }; const listeners = position ? [cond(lessOrEq(position, 0), call([], removeScreen))] : []; updatedSceneData[key] = { ...data, context: { ...data.context, isDismissing: true, shouldRenderScreenContent: false, onExitFinish: removeScreen, }, listeners, }; sceneDataChanged = true; queueAnimation(key, 0); } } if (visibleOverlays !== visibleOverlaysRef.current) { // This indicates we have pushed a new route. Let's make sure every // sceneData has the updated visibleOverlays for (const sceneKey in updatedSceneData) { updatedSceneData[sceneKey] = { ...updatedSceneData[sceneKey], context: { ...updatedSceneData[sceneKey].context, visibleOverlays, }, }; } visibleOverlaysRef.current = visibleOverlays; sceneDataChanged = true; } const pendingAnimations = pendingAnimationsRef.current; React.useEffect(() => { if (Object.keys(pendingAnimations).length === 0) { return; } for (const key in pendingAnimations) { const position = positions[key]; if (!position) { continue; } const toValue = pendingAnimations[key]; let duration = 150; if (isMessageTooltipKey(key)) { const navigationTransitionSpec = toValue === 0 ? TransitionPresets.DefaultTransition.transitionSpec.close : TransitionPresets.DefaultTransition.transitionSpec.open; duration = (navigationTransitionSpec.animation === 'timing' && navigationTransitionSpec.config.duration) || 400; } timing(position, { duration, easing: EasingNode.inOut(EasingNode.ease), toValue, }).start(); } pendingAnimationsRef.current = {}; }, [positions, pendingAnimations]); // If sceneData changes, we update scrollBlockingModalStatus based on it, // both in state and within the individual sceneData contexts. // If sceneData doesn't change, // it's still possible for scrollBlockingModalStatus to change via the // setScrollBlockingModalStatus callback we expose via context let newScrollBlockingModalStatus; if (sceneDataChanged || sceneData !== prevSceneData) { const statusFromSceneData = getScrollBlockingModalStatus( values(updatedSceneData), ); if ( statusFromSceneData !== scrollBlockingModalStatus && statusFromSceneData !== prevScrollBlockingModalStatusFromSceneData ) { newScrollBlockingModalStatus = statusFromSceneData; } prevScrollBlockingModalStatusFromSceneDataRef.current = statusFromSceneData; } if ( !newScrollBlockingModalStatus && scrollBlockingModalStatus !== prevScrollBlockingModalStatus ) { newScrollBlockingModalStatus = scrollBlockingModalStatus; } if (newScrollBlockingModalStatus) { if (newScrollBlockingModalStatus !== scrollBlockingModalStatus) { setScrollBlockingModalStatus(newScrollBlockingModalStatus); } for (const key in updatedSceneData) { const data = updatedSceneData[key]; updatedSceneData[key] = { ...data, context: { ...data.context, scrollBlockingModalStatus: newScrollBlockingModalStatus, }, }; } sceneDataChanged = true; } if (sceneDataChanged) { setSceneData(updatedSceneData); } // Usually this would be done in an effect, // but calling setState from the body // of a hook causes the hook to rerender before triggering effects. To avoid // infinite loops we make sure to set our prev values after we finish // comparing them prevScenesRef.current = scenes; prevSceneDataRef.current = sceneDataChanged ? updatedSceneData : sceneData; prevScrollBlockingModalStatusRef.current = newScrollBlockingModalStatus ? newScrollBlockingModalStatus : scrollBlockingModalStatus; const sceneList = values(updatedSceneData).sort((a, b) => { const routeIndexDifference = a.ordering.routeIndex - b.ordering.routeIndex; if (routeIndexDifference) { return routeIndexDifference; } return a.ordering.creationTime - b.ordering.creationTime; }); const screens = sceneList.map(scene => { const { route, descriptor, context, listeners } = scene; const { render } = descriptor; const pressable = !context.isDismissing && !route.params?.preventPresses; const pointerEvents = pressable ? 'auto' : 'none'; // These listeners are used to clear routes after they finish dismissing const listenerCode = listeners.length > 0 ? : null; return ( {render()} {listenerCode} ); }); return ( {screens} ); }, ); OverlayNavigator.displayName = 'OverlayNavigator'; const createOverlayNavigator: CreateNavigator< StackNavigationState, {}, {}, ExtraNavigatorPropsBase, > = createNavigatorFactory< StackNavigationState, {}, {}, OverlayNavigationHelpers<>, ExtraNavigatorPropsBase, >(OverlayNavigator); const styles = StyleSheet.create({ container: { flex: 1, }, scene: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); export { createOverlayNavigator }; diff --git a/native/navigation/route-names.js b/native/navigation/route-names.js index 623993525..186ab7747 100644 --- a/native/navigation/route-names.js +++ b/native/navigation/route-names.js @@ -1,391 +1,393 @@ // @flow import type { RouteProp } from '@react-navigation/core'; import type { ActionResultModalParams } from './action-result-modal.react.js'; import type { InviteLinkModalParams } from './invite-link-modal.react'; import type { AvatarSelectionParams } from '../account/registration/avatar-selection.react.js'; import type { ConnectEthereumParams } from '../account/registration/connect-ethereum.react.js'; import type { ConnectFarcasterParams } from '../account/registration/connect-farcaster.react.js'; import type { EmojiAvatarSelectionParams } from '../account/registration/emoji-avatar-selection.react.js'; import type { ExistingEthereumAccountParams } from '../account/registration/existing-ethereum-account.react.js'; import type { KeyserverSelectionParams } from '../account/registration/keyserver-selection.react.js'; import type { PasswordSelectionParams } from '../account/registration/password-selection.react.js'; import type { RegistrationTermsParams } from '../account/registration/registration-terms.react.js'; import type { CreateSIWEBackupMessageParams } from '../account/registration/siwe-backup-message-creation.react.js'; import type { UsernameSelectionParams } from '../account/registration/username-selection.react.js'; import type { TermsAndPrivacyModalParams } from '../account/terms-and-privacy-modal.react.js'; import type { RestoreSIWEBackupParams } from '../backup/restore-siwe-backup.react.js'; import type { ThreadPickerModalParams } from '../calendar/thread-picker-modal.react.js'; import type { ComposeSubchannelParams } from '../chat/compose-subchannel.react.js'; import type { FullScreenThreadMediaGalleryParams } from '../chat/fullscreen-thread-media-gallery.react.js'; import type { ImagePasteModalParams } from '../chat/image-paste-modal.react.js'; import type { MessageListParams } from '../chat/message-list-types.js'; import type { MessageReactionsModalParams } from '../chat/message-reactions-modal.react.js'; import type { MultimediaMessageTooltipModalParams } from '../chat/multimedia-message-tooltip-modal.react.js'; import type { PinnedMessagesScreenParams } from '../chat/pinned-messages-screen.react.js'; import type { RobotextMessageTooltipModalParams } from '../chat/robotext-message-tooltip-modal.react.js'; import type { AddUsersModalParams } from '../chat/settings/add-users-modal.react.js'; import type { ColorSelectorModalParams } from '../chat/settings/color-selector-modal.react.js'; import type { ComposeSubchannelModalParams } from '../chat/settings/compose-subchannel-modal.react.js'; import type { DeleteThreadParams } from '../chat/settings/delete-thread.react.js'; import type { EmojiThreadAvatarCreationParams } from '../chat/settings/emoji-thread-avatar-creation.react.js'; import type { ThreadSettingsMemberTooltipModalParams } from '../chat/settings/thread-settings-member-tooltip-modal.react.js'; import type { ThreadSettingsNotificationsParams } from '../chat/settings/thread-settings-notifications.react.js'; import type { ThreadSettingsParams } from '../chat/settings/thread-settings.react.js'; import type { SidebarListModalParams } from '../chat/sidebar-list-modal.react.js'; import type { SubchannelListModalParams } from '../chat/subchannels-list-modal.react.js'; import type { TextMessageTooltipModalParams } from '../chat/text-message-tooltip-modal.react.js'; import type { TogglePinModalParams } from '../chat/toggle-pin-modal.react.js'; import type { TagFarcasterChannelByNameParams } from '../community-settings/tag-farcaster-channel/tag-farcaster-channel-by-name.react.js'; import type { TagFarcasterChannelParams } from '../community-settings/tag-farcaster-channel/tag-farcaster-channel.react.js'; import type { InviteLinksNavigatorParams } from '../invite-links/invite-links-navigator.react.js'; import type { ManagePublicLinkScreenParams } from '../invite-links/manage-public-link-screen.react.js'; import type { ViewInviteLinksScreenParams } from '../invite-links/view-invite-links-screen.react.js'; import type { ChatCameraModalParams } from '../media/chat-camera-modal.react.js'; import type { ImageModalParams } from '../media/image-modal.react.js'; import type { ThreadAvatarCameraModalParams } from '../media/thread-avatar-camera-modal.react.js'; import type { VideoPlaybackModalParams } from '../media/video-playback-modal.react.js'; import type { CustomServerModalParams } from '../profile/custom-server-modal.react.js'; import type { KeyserverSelectionBottomSheetParams } from '../profile/keyserver-selection-bottom-sheet.react.js'; import type { UserRelationshipTooltipModalParams } from '../profile/user-relationship-tooltip-modal.react.js'; import type { ChangeRolesScreenParams } from '../roles/change-roles-screen.react.js'; import type { CommunityRolesScreenParams } from '../roles/community-roles-screen.react.js'; import type { CreateRolesScreenParams } from '../roles/create-roles-screen.react.js'; import type { MessageSearchParams } from '../search/message-search.react.js'; import type { NUXTipsOverlayParams } from '../tooltip/nux-tips-overlay.react.js'; import type { UserProfileAvatarModalParams } from '../user-profile/user-profile-avatar-modal.react.js'; import type { UserProfileBottomSheetParams } from '../user-profile/user-profile-bottom-sheet.react.js'; export const ActionResultModalRouteName = 'ActionResultModal'; export const AddUsersModalRouteName = 'AddUsersModal'; export const AppearancePreferencesRouteName = 'AppearancePreferences'; export const AppRouteName = 'App'; export const AppsRouteName = 'Apps'; export const BackgroundChatThreadListRouteName = 'BackgroundChatThreadList'; export const BackupMenuRouteName = 'BackupMenu'; export const BlockListRouteName = 'BlockList'; export const BuildInfoRouteName = 'BuildInfo'; export const CalendarRouteName = 'Calendar'; export const CalendarScreenRouteName = 'CalendarScreen'; export const ChangeRolesScreenRouteName = 'ChangeRolesScreen'; export const ChatCameraModalRouteName = 'ChatCameraModal'; export const ChatRouteName = 'Chat'; export const ChatThreadListRouteName = 'ChatThreadList'; export const ColorSelectorModalRouteName = 'ColorSelectorModal'; export const ComposeSubchannelModalRouteName = 'ComposeSubchannelModal'; export const ComposeSubchannelRouteName = 'ComposeSubchannel'; export const CommunityDrawerNavigatorRouteName = 'CommunityDrawerNavigator'; export const CustomServerModalRouteName = 'CustomServerModal'; export const DefaultNotificationsPreferencesRouteName = 'DefaultNotifications'; export const DeleteAccountRouteName = 'DeleteAccount'; export const DeleteThreadRouteName = 'DeleteThread'; export const DevToolsRouteName = 'DevTools'; export const EditPasswordRouteName = 'EditPassword'; export const EmojiThreadAvatarCreationRouteName = 'EmojiThreadAvatarCreation'; export const EmojiUserAvatarCreationRouteName = 'EmojiUserAvatarCreation'; export const FriendListRouteName = 'FriendList'; export const FullScreenThreadMediaGalleryRouteName = 'FullScreenThreadMediaGallery'; export const HomeChatThreadListRouteName = 'HomeChatThreadList'; export const ImageModalRouteName = 'ImageModal'; export const ImagePasteModalRouteName = 'ImagePasteModal'; export const InviteLinkModalRouteName = 'InviteLinkModal'; export const InviteLinkNavigatorRouteName = 'InviteLinkNavigator'; export const LinkedDevicesRouteName = 'LinkedDevices'; export const LinkedDevicesBottomSheetRouteName = 'LinkedDevicesBottomSheet'; export const LoggedOutModalRouteName = 'LoggedOutModal'; export const ManagePublicLinkRouteName = 'ManagePublicLink'; export const MessageListRouteName = 'MessageList'; export const MessageReactionsModalRouteName = 'MessageReactionsModal'; export const PinnedMessagesScreenRouteName = 'PinnedMessagesScreen'; export const MultimediaMessageTooltipModalRouteName = 'MultimediaMessageTooltipModal'; export const PrivacyPreferencesRouteName = 'PrivacyPreferences'; export const ProfileRouteName = 'Profile'; export const ProfileScreenRouteName = 'ProfileScreen'; export const UserRelationshipTooltipModalRouteName = 'UserRelationshipTooltipModal'; export const RobotextMessageTooltipModalRouteName = 'RobotextMessageTooltipModal'; export const SecondaryDeviceQRCodeScannerRouteName = 'SecondaryDeviceQRCodeScanner'; export const SidebarListModalRouteName = 'SidebarListModal'; export const SubchannelsListModalRouteName = 'SubchannelsListModal'; export const TabNavigatorRouteName = 'TabNavigator'; export const TextMessageTooltipModalRouteName = 'TextMessageTooltipModal'; export const ThreadAvatarCameraModalRouteName = 'ThreadAvatarCameraModal'; export const ThreadPickerModalRouteName = 'ThreadPickerModal'; export const ThreadSettingsMemberTooltipModalRouteName = 'ThreadSettingsMemberTooltipModal'; export const ThreadSettingsRouteName = 'ThreadSettings'; export const TunnelbrokerMenuRouteName = 'TunnelbrokerMenu'; export const UserAvatarCameraModalRouteName = 'UserAvatarCameraModal'; export const TogglePinModalRouteName = 'TogglePinModal'; export const VideoPlaybackModalRouteName = 'VideoPlaybackModal'; export const ViewInviteLinksRouteName = 'ViewInviteLinks'; export const TermsAndPrivacyRouteName = 'TermsAndPrivacyModal'; export const RegistrationRouteName = 'Registration'; export const KeyserverSelectionRouteName = 'KeyserverSelection'; export const CoolOrNerdModeSelectionRouteName = 'CoolOrNerdModeSelection'; export const ConnectEthereumRouteName = 'ConnectEthereum'; export const CreateSIWEBackupMessageRouteName = 'CreateSIWEBackupMessage'; export const CreateMissingSIWEBackupMessageRouteName = 'CreateMissingSIWEBackupMessage'; export const RestoreSIWEBackupRouteName = 'RestoreSIWEBackup'; export const ExistingEthereumAccountRouteName = 'ExistingEthereumAccount'; export const ConnectFarcasterRouteName = 'ConnectFarcaster'; export const UsernameSelectionRouteName = 'UsernameSelection'; export const CommunityCreationRouteName = 'CommunityCreation'; export const CommunityConfigurationRouteName = 'CommunityConfiguration'; export const MessageSearchRouteName = 'MessageSearch'; export const PasswordSelectionRouteName = 'PasswordSelection'; export const AvatarSelectionRouteName = 'AvatarSelection'; export const EmojiAvatarSelectionRouteName = 'EmojiAvatarSelection'; export const RegistrationUserAvatarCameraModalRouteName = 'RegistrationUserAvatarCameraModal'; export const RegistrationTermsRouteName = 'RegistrationTerms'; export const RolesNavigatorRouteName = 'RolesNavigator'; export const CommunityRolesScreenRouteName = 'CommunityRolesScreen'; export const CreateRolesScreenRouteName = 'CreateRolesScreen'; export const QRCodeSignInNavigatorRouteName = 'QRCodeSignInNavigator'; export const QRCodeScreenRouteName = 'QRCodeScreen'; export const UserProfileBottomSheetNavigatorRouteName = 'UserProfileBottomSheetNavigator'; export const UserProfileBottomSheetRouteName = 'UserProfileBottomSheet'; export const UserProfileAvatarModalRouteName = 'UserProfileAvatarModal'; export const KeyserverSelectionListRouteName = 'KeyserverSelectionList'; export const AddKeyserverRouteName = 'AddKeyserver'; export const KeyserverSelectionBottomSheetRouteName = 'KeyserverSelectionBottomSheet'; export const AccountDoesNotExistRouteName = 'AccountDoesNotExist'; export const FarcasterAccountSettingsRouteName = 'FarcasterAccountSettings'; export const ConnectFarcasterBottomSheetRouteName = 'ConnectFarcasterBottomSheet'; export const TagFarcasterChannelNavigatorRouteName = 'TagFarcasterChannelNavigator'; export const TagFarcasterChannelRouteName = 'TagFarcasterChannel'; export const TagFarcasterChannelByNameRouteName = 'TagFarcasterChannelByName'; export const ThreadSettingsNotificationsRouteName = 'ThreadSettingsNotifications'; export const CommunityDrawerTipRouteName = 'CommunityDrawerTip'; export const MutedTabTipRouteName = 'MutedTabTip'; +export const NUXTipOverlayBackdropRouteName = 'NUXTipOverlayBackdrop'; export type RootParamList = { +LoggedOutModal: void, +App: void, +ThreadPickerModal: ThreadPickerModalParams, +AddUsersModal: AddUsersModalParams, +CustomServerModal: CustomServerModalParams, +ColorSelectorModal: ColorSelectorModalParams, +ComposeSubchannelModal: ComposeSubchannelModalParams, +SidebarListModal: SidebarListModalParams, +ImagePasteModal: ImagePasteModalParams, +TermsAndPrivacyModal: TermsAndPrivacyModalParams, +SubchannelsListModal: SubchannelListModalParams, +MessageReactionsModal: MessageReactionsModalParams, +Registration: void, +CommunityCreation: void, +InviteLinkModal: InviteLinkModalParams, +InviteLinkNavigator: InviteLinksNavigatorParams, +RolesNavigator: void, +QRCodeSignInNavigator: void, +UserProfileBottomSheetNavigator: void, +TunnelbrokerMenu: void, +KeyserverSelectionBottomSheet: KeyserverSelectionBottomSheetParams, +LinkedDevicesBottomSheet: void, +ConnectFarcasterBottomSheet: void, +TagFarcasterChannelNavigator: void, +CreateMissingSIWEBackupMessage: void, +RestoreSIWEBackup: RestoreSIWEBackupParams, }; export type NUXTipRouteNames = | typeof CommunityDrawerTipRouteName | typeof MutedTabTipRouteName; export type MessageTooltipRouteNames = | typeof RobotextMessageTooltipModalRouteName | typeof MultimediaMessageTooltipModalRouteName | typeof TextMessageTooltipModalRouteName; export const PinnableMessageTooltipRouteNames = [ TextMessageTooltipModalRouteName, MultimediaMessageTooltipModalRouteName, ]; export type TooltipModalParamList = { +MultimediaMessageTooltipModal: MultimediaMessageTooltipModalParams, +TextMessageTooltipModal: TextMessageTooltipModalParams, +ThreadSettingsMemberTooltipModal: ThreadSettingsMemberTooltipModalParams, +UserRelationshipTooltipModal: UserRelationshipTooltipModalParams, +RobotextMessageTooltipModal: RobotextMessageTooltipModalParams, }; export type OverlayParamList = { +CommunityDrawerNavigator: void, +ImageModal: ImageModalParams, +ActionResultModal: ActionResultModalParams, +ChatCameraModal: ChatCameraModalParams, +UserAvatarCameraModal: void, +ThreadAvatarCameraModal: ThreadAvatarCameraModalParams, +VideoPlaybackModal: VideoPlaybackModalParams, +TogglePinModal: TogglePinModalParams, +CommunityDrawerTip: NUXTipsOverlayParams, +MutedTabTip: NUXTipsOverlayParams, + +NUXTipOverlayBackdrop: void, ...TooltipModalParamList, }; export type TabParamList = { +Calendar: void, +Chat: void, +Profile: void, +Apps: void, }; export type ChatParamList = { +ChatThreadList: void, +MessageList: MessageListParams, +ComposeSubchannel: ComposeSubchannelParams, +ThreadSettings: ThreadSettingsParams, +EmojiThreadAvatarCreation: EmojiThreadAvatarCreationParams, +DeleteThread: DeleteThreadParams, +FullScreenThreadMediaGallery: FullScreenThreadMediaGalleryParams, +PinnedMessagesScreen: PinnedMessagesScreenParams, +MessageSearch: MessageSearchParams, +ChangeRolesScreen: ChangeRolesScreenParams, +ThreadSettingsNotifications: ThreadSettingsNotificationsParams, }; export type ChatTopTabsParamList = { +HomeChatThreadList: void, +BackgroundChatThreadList: void, }; export type ProfileParamList = { +ProfileScreen: void, +EmojiUserAvatarCreation: void, +EditPassword: void, +DeleteAccount: void, +BuildInfo: void, +DevTools: void, +AppearancePreferences: void, +PrivacyPreferences: void, +DefaultNotifications: void, +FriendList: void, +BlockList: void, +LinkedDevices: void, +SecondaryDeviceQRCodeScanner: void, +BackupMenu: void, +TunnelbrokerMenu: void, +KeyserverSelectionList: void, +AddKeyserver: void, +FarcasterAccountSettings: void, }; export type CalendarParamList = { +CalendarScreen: void, }; export type CommunityDrawerParamList = { +TabNavigator: void }; export type RegistrationParamList = { +CoolOrNerdModeSelection: void, +KeyserverSelection: KeyserverSelectionParams, +ConnectEthereum: ConnectEthereumParams, +ExistingEthereumAccount: ExistingEthereumAccountParams, +ConnectFarcaster: ConnectFarcasterParams, +CreateSIWEBackupMessage: CreateSIWEBackupMessageParams, +UsernameSelection: UsernameSelectionParams, +PasswordSelection: PasswordSelectionParams, +AvatarSelection: AvatarSelectionParams, +EmojiAvatarSelection: EmojiAvatarSelectionParams, +RegistrationUserAvatarCameraModal: void, +RegistrationTerms: RegistrationTermsParams, +AccountDoesNotExist: void, }; export type InviteLinkParamList = { +ViewInviteLinks: ViewInviteLinksScreenParams, +ManagePublicLink: ManagePublicLinkScreenParams, }; export type CommunityCreationParamList = { +CommunityConfiguration: void, }; export type RolesParamList = { +CommunityRolesScreen: CommunityRolesScreenParams, +CreateRolesScreen: CreateRolesScreenParams, }; export type TagFarcasterChannelParamList = { +TagFarcasterChannel: TagFarcasterChannelParams, +TagFarcasterChannelByName: TagFarcasterChannelByNameParams, }; export type QRCodeSignInParamList = { +QRCodeScreen: void, }; export type UserProfileBottomSheetParamList = { +UserProfileBottomSheet: UserProfileBottomSheetParams, +UserProfileAvatarModal: UserProfileAvatarModalParams, +UserRelationshipTooltipModal: UserRelationshipTooltipModalParams, }; export type ScreenParamList = { ...RootParamList, ...OverlayParamList, ...TabParamList, ...ChatParamList, ...ChatTopTabsParamList, ...ProfileParamList, ...CalendarParamList, ...CommunityDrawerParamList, ...RegistrationParamList, ...InviteLinkParamList, ...CommunityCreationParamList, ...RolesParamList, ...QRCodeSignInParamList, ...UserProfileBottomSheetParamList, ...TagFarcasterChannelParamList, }; export type NavigationRoute> = RouteProp; export const accountModals = [ LoggedOutModalRouteName, RegistrationRouteName, QRCodeSignInNavigatorRouteName, ]; export const scrollBlockingModals = [ ImageModalRouteName, MultimediaMessageTooltipModalRouteName, TextMessageTooltipModalRouteName, ThreadSettingsMemberTooltipModalRouteName, UserRelationshipTooltipModalRouteName, RobotextMessageTooltipModalRouteName, VideoPlaybackModalRouteName, ]; export const chatRootModals = [ AddUsersModalRouteName, ColorSelectorModalRouteName, ComposeSubchannelModalRouteName, ]; export const threadRoutes = [ MessageListRouteName, ThreadSettingsRouteName, DeleteThreadRouteName, ComposeSubchannelRouteName, FullScreenThreadMediaGalleryRouteName, PinnedMessagesScreenRouteName, MessageSearchRouteName, EmojiThreadAvatarCreationRouteName, CommunityRolesScreenRouteName, ThreadSettingsNotificationsRouteName, ]; diff --git a/native/tooltip/nux-tips-overlay.react.js b/native/tooltip/nux-tips-overlay.react.js index 13b89a4b4..aaddbaa43 100644 --- a/native/tooltip/nux-tips-overlay.react.js +++ b/native/tooltip/nux-tips-overlay.react.js @@ -1,446 +1,412 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, TouchableWithoutFeedback, Platform, Text } from 'react-native'; import Animated, { FadeOut, withTiming, // eslint-disable-next-line no-unused-vars type EntryAnimationsValues, // eslint-disable-next-line no-unused-vars type ExitAnimationsValues, } from 'react-native-reanimated'; import Button from '../components/button.react.js'; import { getNUXTipParams, NUXTipsContext, type NUXTip, } from '../components/nux-tips-context.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { NavigationRoute, NUXTipRouteNames, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import { AnimatedView } from '../types/styles.js'; import type { WritableAnimatedStyleObj } from '../types/styles.js'; const { Value } = Animated; const animationDuration = 150; const unboundStyles = { - backdrop: { - backgroundColor: 'black', - bottom: 0, - left: 0, - position: 'absolute', - right: 0, - top: 0, - }, container: { flex: 1, }, contentContainer: { flex: 1, overflow: 'hidden', }, items: { backgroundColor: 'tooltipBackground', borderRadius: 5, overflow: 'hidden', paddingHorizontal: 20, paddingTop: 20, paddingBottom: 18, }, 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, }, 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, }, tipText: { color: 'panelForegroundLabel', fontSize: 18, marginBottom: 10, }, buttonContainer: { alignSelf: 'flex-end', }, okButtonText: { fontSize: 18, color: 'panelForegroundLabel', textAlign: 'center', paddingVertical: 10, paddingHorizontal: 20, }, okButton: { backgroundColor: 'purpleButton', borderRadius: 8, }, }; export type NUXTipsOverlayParams = { +tipKey: NUXTip, +tooltipLocation: 'above' | 'below', }; export type NUXTipsOverlayProps = { +navigation: AppNavigationProp, +route: NavigationRoute, }; const marginVertical: number = 20; const marginHorizontal: number = 10; -function opacityEnteringAnimation() { - 'worklet'; - - return { - animations: { - opacity: withTiming(0.7, { duration: animationDuration }), - }, - initialValues: { - opacity: 0, - }, - }; -} - function createNUXTipsOverlay( ButtonComponent: React.ComponentType>, tipText: string, ): React.ComponentType> { function NUXTipsOverlay(props: NUXTipsOverlayProps) { const nuxTipContext = React.useContext(NUXTipsContext); const { navigation, route } = props; const { initialCoordinates, verticalBounds } = React.useMemo(() => { const tipsProps = nuxTipContext?.tipsProps; invariant(tipsProps, 'tips props should be defined in nux tips overlay'); const { pageX, pageY, width, height } = tipsProps[route.params.tipKey]; return { initialCoordinates: { height, width, x: pageX, y: pageY }, verticalBounds: { height, y: pageY }, }; }, [nuxTipContext, route.params.tipKey]); const dimensions = useSelector(state => state.dimensions); const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'NUXTipsOverlay should have OverlayContext'); const { onExitFinish } = overlayContext; const { goBackOnce } = navigation; const styles = useStyles(unboundStyles); const contentContainerStyle = React.useMemo(() => { const fullScreenHeight = dimensions.height; const top = verticalBounds.y; const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height; return { ...styles.contentContainer, marginTop: top, marginBottom: bottom, }; }, [ dimensions.height, styles.contentContainer, verticalBounds.height, verticalBounds.y, ]); const buttonStyle = React.useMemo(() => { const { x, y, width, height } = initialCoordinates; return { width: Math.ceil(width), height: Math.ceil(height), marginTop: y - verticalBounds.y, marginLeft: x, }; }, [initialCoordinates, verticalBounds]); const tipHorizontalOffsetRef = React.useRef(new Value(0)); const tipHorizontalOffset = tipHorizontalOffsetRef.current; const onTipContainerLayout = React.useCallback( (event: LayoutEvent) => { const { x, width } = initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; const actualWidth = event.nativeEvent.layout.width; if (extraLeftSpace < extraRightSpace) { const minWidth = width + 2 * extraLeftSpace; tipHorizontalOffset.setValue((minWidth - actualWidth) / 2); } else { const minWidth = width + 2 * extraRightSpace; tipHorizontalOffset.setValue((actualWidth - minWidth) / 2); } }, [dimensions.width, initialCoordinates, tipHorizontalOffset], ); const { tooltipLocation } = route.params; const baseTipContainerStyle = React.useMemo(() => { const { y, x, height, width } = initialCoordinates; const style: WritableAnimatedStyleObj = { position: 'absolute', alignItems: 'center', }; if (tooltipLocation === 'below') { style.top = Math.min(y + height, verticalBounds.y + verticalBounds.height) + marginVertical; } else { style.bottom = dimensions.height - Math.max(y, verticalBounds.y) + marginVertical; } const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { style.left = marginHorizontal; style.minWidth = width + 2 * extraLeftSpace; style.marginRight = 2 * marginHorizontal; } else { style.right = marginHorizontal; style.minWidth = width + 2 * extraRightSpace; style.marginLeft = 2 * marginHorizontal; } return style; }, [ dimensions.height, dimensions.width, initialCoordinates, tooltipLocation, verticalBounds.height, verticalBounds.y, ]); const triangleStyle = React.useMemo(() => { const { x, width } = initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { return { alignSelf: 'flex-start', left: extraLeftSpace + (4 / 10) * width - marginHorizontal, }; } else { return { alignSelf: 'flex-end', right: extraRightSpace + (4 / 10) * width - marginHorizontal, }; } }, [dimensions.width, initialCoordinates]); - const opacityExitingAnimation = React.useCallback(() => { - 'worklet'; - - return { - animations: { - opacity: withTiming(0, { duration: animationDuration }), - }, - initialValues: { - opacity: 0.7, - }, - callback: onExitFinish, - }; - }, [onExitFinish]); - // prettier-ignore const tipContainerEnteringAnimation = React.useCallback( (values/*: EntryAnimationsValues*/) => { 'worklet'; const initialX = (-values.targetWidth + initialCoordinates.width + initialCoordinates.x) / 2; const initialY = tooltipLocation === 'below' ? -values.targetHeight / 2 : values.targetHeight / 2; return { animations: { opacity: withTiming(1, { duration: animationDuration }), transform: [ { translateX: withTiming(0, { duration: animationDuration }) }, { translateY: withTiming(0, { duration: animationDuration }) }, { scale: withTiming(1, { duration: animationDuration }) }, ], }, initialValues: { opacity: 0, transform: [ { translateX: initialX }, { translateY: initialY }, { scale: 0 }, ], }, }; }, [initialCoordinates.width, initialCoordinates.x, tooltipLocation], ); // prettier-ignore const tipContainerExitingAnimation = React.useCallback( (values/*: ExitAnimationsValues*/) => { 'worklet'; const toValueX = (-values.currentWidth + initialCoordinates.width + initialCoordinates.x) / 2; const toValueY = tooltipLocation === 'below' ? -values.currentHeight / 2 : values.currentHeight / 2;; return { animations: { opacity: withTiming(0, { duration: animationDuration }), transform: [ { translateX: withTiming(toValueX, { duration: animationDuration, }), }, { translateY: withTiming(toValueY, { duration: animationDuration, }), }, { scale: withTiming(0, { duration: animationDuration }) }, ], }, initialValues: { opacity: 1, transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }], }, + callback: onExitFinish, }; }, - [initialCoordinates.width, initialCoordinates.x, tooltipLocation], + [initialCoordinates.width, initialCoordinates.x, onExitFinish, tooltipLocation], ); let triangleDown = null; let triangleUp = null; if (tooltipLocation === 'above') { triangleDown = ; } else if (tooltipLocation === 'below') { triangleUp = ; } - const callbackParams = getNUXTipParams(route.params.tipKey); - const onPressOk = React.useCallback(() => { + const callbackParams = getNUXTipParams(route.params.tipKey); + const { nextTip, tooltipLocation: nextLocation, nextRouteName, + exitingCallback, } = callbackParams; goBackOnce(); + if (exitingCallback) { + exitingCallback?.(navigation); + } + if (!nextTip || !nextRouteName) { return; } navigation.navigate({ name: nextRouteName, params: { tipKey: nextTip, tooltipLocation: nextLocation, }, }); - }, [callbackParams, goBackOnce, navigation]); + }, [goBackOnce, navigation, route.params.tipKey]); return ( - {triangleUp} {tipText} {triangleDown} ); } function NUXTipsOverlayWrapper(props: NUXTipsOverlayProps) { const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'NUXTipsOverlay should have OverlayContext'); const { shouldRenderScreenContent } = overlayContext; return shouldRenderScreenContent ? : null; } return React.memo>(NUXTipsOverlayWrapper); } -export { createNUXTipsOverlay }; +export { createNUXTipsOverlay, animationDuration };