diff --git a/native/components/nux-tips-context.react.js b/native/components/nux-tips-context.react.js index c3b35801e..8ef520e6e 100644 --- a/native/components/nux-tips-context.react.js +++ b/native/components/nux-tips-context.react.js @@ -1,72 +1,97 @@ // @flow import * as React from 'react'; import { values } from 'lib/utils/objects.js'; +import type { NUXTipRouteNames } from '../navigation/route-names.js'; + const nuxTip = Object.freeze({ - MUTED: 'muted', COMMUNITY_DRAWER: 'community_drawer', + MUTED: 'muted', }); -type NUXTip = $Values; +export type NUXTip = $Values; + +type NUXTipParams = { + +nextTip: ?NUXTip, + +tooltipLocation: 'below' | 'above', + +nextRouteName: ?NUXTipRouteNames, +}; + +const nuxTipParams: { [NUXTip]: NUXTipParams } = { + [nuxTip.COMMUNITY_DRAWER]: { + nextTip: nuxTip.MUTED, + tooltipLocation: 'below', + nextRouteName: undefined, //TODO: update to the next screens name + }, + [nuxTip.MUTED]: { + nextTip: undefined, + nextRouteName: undefined, + tooltipLocation: 'below', + }, +}; + +function getNUXTipParams(currentTipKey: NUXTip): NUXTipParams { + return nuxTipParams[currentTipKey]; +} type TipProps = { - +x: ?number, - +y: ?number, - +width: ?number, - +height: ?number, - +pageX: ?number, - +pageY: ?number, + +x: number, + +y: number, + +width: number, + +height: number, + +pageX: number, + +pageY: number, }; export type NUXTipsContextType = { +registerTipButton: (type: NUXTip, tipProps: ?TipProps) => void, +getTipsProps: () => ?{ +[type: NUXTip]: TipProps }, }; const NUXTipsContext: React.Context = React.createContext(); type Props = { +children: React.Node, }; function NUXTipsContextProvider(props: Props): React.Node { const { children } = props; const tipsProps = React.useRef<{ [tip: NUXTip]: ?TipProps }>({}); const registerTipButton = React.useCallback( (type: NUXTip, tipProps: ?TipProps) => { tipsProps.current[type] = tipProps; }, [], ); const getTipsProps = React.useCallback(() => { const result: { [tip: NUXTip]: TipProps } = {}; for (const type of values(nuxTip)) { if (!tipsProps.current[type]) { return null; } result[type] = tipsProps.current[type]; } return result; }, []); const value = React.useMemo( () => ({ registerTipButton, getTipsProps, }), [getTipsProps, registerTipButton], ); return ( {children} ); } -export { NUXTipsContext, NUXTipsContextProvider, nuxTip }; +export { NUXTipsContext, NUXTipsContextProvider, nuxTip, getNUXTipParams }; diff --git a/native/navigation/overlay-navigator.react.js b/native/navigation/overlay-navigator.react.js index 6842f2eae..fd28ef952 100644 --- a/native/navigation/overlay-navigator.react.js +++ b/native/navigation/overlay-navigator.react.js @@ -1,571 +1,571 @@ // @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, - NUXTipsOverlayRouteName, + CommunityDrawerTipRouteName, } from './route-names.js'; import { isMessageTooltipKey } from '../chat/utils.js'; -const newReanimatedRoutes = new Set([NUXTipsOverlayRouteName]); +const newReanimatedRoutes = new Set([CommunityDrawerTipRouteName]); 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 ee1ddbdc5..4ef97924d 100644 --- a/native/navigation/route-names.js +++ b/native/navigation/route-names.js @@ -1,383 +1,385 @@ // @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 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 NUXTipsOverlayRouteName = 'NUXTipsOverlay'; +export const CommunityDrawerTipRouteName = 'CommunityDrawerTip'; 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, +ConnectFarcasterBottomSheet: void, +TagFarcasterChannelNavigator: void, +CreateMissingSIWEBackupMessage: void, +RestoreSIWEBackup: RestoreSIWEBackupParams, }; +export type NUXTipRouteNames = typeof CommunityDrawerTipRouteName; + 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, - +NUXTipsOverlay: NUXTipsOverlayParams, + +CommunityDrawerTip: NUXTipsOverlayParams, ...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 ddd740b20..0bb980703 100644 --- a/native/tooltip/nux-tips-overlay.react.js +++ b/native/tooltip/nux-tips-overlay.react.js @@ -1,379 +1,382 @@ // @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 type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { OverlayContext } from '../navigation/overlay-context.js'; -import type { NavigationRoute } from '../navigation/route-names.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 { LayoutCoordinates, VerticalBounds, } from '../types/layout-types.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', }, 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: 20, padding: 15, }, }; export type NUXTipsOverlayParams = { +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +tooltipLocation: 'above' | 'below', }; -export type NUXTipsOverlayProps = { - +navigation: AppNavigationProp<'NUXTipsOverlay'>, - +route: NavigationRoute<'NUXTipsOverlay'>, +export type NUXTipsOverlayProps = { + +navigation: AppNavigationProp, + +route: NavigationRoute, }; const margin: number = 20; function opacityEnteringAnimation() { 'worklet'; return { animations: { opacity: withTiming(0.7, { duration: animationDuration }), }, initialValues: { opacity: 0, }, }; } -function createNUXTipsOverlay( - ButtonComponent: React.ComponentType, +function createNUXTipsOverlay( + ButtonComponent: React.ComponentType>, tipText: string, -): React.ComponentType { - function NUXTipsOverlay(props: NUXTipsOverlayProps) { +): React.ComponentType> { + function NUXTipsOverlay(props: NUXTipsOverlayProps) { const dimensions = useSelector(state => state.dimensions); const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'NUXTipsOverlay should have OverlayContext'); const { onExitFinish } = overlayContext; const { navigation, route } = props; const { goBackOnce } = navigation; const styles = useStyles(unboundStyles); const contentContainerStyle = React.useMemo(() => { const { verticalBounds } = route.params; const fullScreenHeight = dimensions.height; const top = verticalBounds.y; const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height; return { ...styles.contentContainer, marginTop: top, marginBottom: bottom, }; }, [dimensions.height, route.params, styles.contentContainer]); const { initialCoordinates, verticalBounds } = props.route.params; 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) + margin; } else { style.bottom = dimensions.height - Math.max(y, verticalBounds.y) + margin; } const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { style.left = 0; style.minWidth = width + 2 * extraLeftSpace; } else { style.right = 0; style.minWidth = width + 2 * extraRightSpace; } 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, }; } else { return { alignSelf: 'flex-end', right: extraRightSpace + (4 / 10) * width, }; } }, [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 }], }, }; }, [initialCoordinates.width, initialCoordinates.x, tooltipLocation], ); let triangleDown = null; let triangleUp = null; if (tooltipLocation === 'above') { triangleDown = ; } else if (tooltipLocation === 'below') { triangleUp = ; } return ( {triangleUp} {tipText} {triangleDown} ); } - function NUXTipsOverlayWrapper(props: NUXTipsOverlayProps) { + 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); + return React.memo>(NUXTipsOverlayWrapper); } export { createNUXTipsOverlay };