/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import React, { createContext, useCallback, useContext, useEffect, useState, } from 'react'; import {Badge, Tooltip, Typography, Button} from 'antd'; import styled from '@emotion/styled'; import reactElementToJSXString from 'react-element-to-jsx-string'; import {SandyPluginContext} from '../plugin/PluginContext'; import {createState} from 'flipper-plugin-core'; import {useValue} from '../state/atom'; import {_SandyDevicePluginInstance} from 'flipper-plugin-core'; import {Layout} from './Layout'; import {BulbTwoTone} from '@ant-design/icons'; // This import is OK since it is a type-only import // eslint-disable-next-line no-restricted-imports import type {TooltipPlacement} from 'antd/lib/tooltip'; import {_SandyPluginInstance} from 'flipper-plugin-core'; import {theme} from './theme'; import {Tracked} from './Tracked'; import {sha256} from '../utils/sha256'; const {Text} = Typography; type NuxManager = ReturnType; const storageKey = `FLIPPER_NUX_STATE`; export async function getNuxKey( elem: React.ReactNode, currentPlugin?: _SandyPluginInstance | _SandyDevicePluginInstance, ): Promise { const hash = await sha256(reactElementToJSXString(elem)); return `${currentPlugin?.definition.id ?? 'flipper'}:${hash}`; } export function createNuxManager() { const ticker = createState(0); let readMap: Record = JSON.parse( window.localStorage.getItem(storageKey) || '{}', ); function save() { // trigger all Nux Elements to re-compute state ticker.set(ticker.get() + 1); window.localStorage.setItem(storageKey, JSON.stringify(readMap, null, 2)); } return { async markRead( elem: React.ReactNode, currentPlugin?: _SandyPluginInstance | _SandyDevicePluginInstance, ): Promise { readMap[await getNuxKey(elem, currentPlugin)] = true; save(); }, async isRead( elem: React.ReactNode, currentPlugin?: _SandyPluginInstance | _SandyDevicePluginInstance, ): Promise { return !!readMap[await getNuxKey(elem, currentPlugin)]; }, resetHints(): void { readMap = {}; save(); }, ticker, }; } const stubManager: NuxManager = { async markRead() {}, async isRead() { return true; }, resetHints() {}, ticker: createState(0), }; export const NuxManagerContext = createContext(stubManager); /** * Creates a New-User-eXperience element; a lightbulb that will show the user new features */ export function NUX({ children, title, placement, }: { children: React.ReactNode; title: string; placement?: TooltipPlacement; }) { const manager = useContext(NuxManagerContext); const pluginInstance = useContext(SandyPluginContext); // changing the ticker will force `isRead` to be recomputed const _tick = useValue(manager.ticker); // start with Read = true until proven otherwise, to avoid Nux glitches const [isRead, setIsRead] = useState(true); useEffect(() => { manager .isRead(title, pluginInstance) .then(setIsRead) .catch((e) => { console.warn('Failed to read NUX status', e); }); }, [manager, title, pluginInstance, _tick]); const dismiss = useCallback(() => { manager.markRead(title, pluginInstance); }, [title, manager, pluginInstance]); return ( {title} }> ) }> {children} ); } // We force visibility of the badge to invisible if count has dropped, // otherwise ANT will await animation end, which looks really awkard, see D24918536 const UnanimatedBadge = styled(Badge)(({count}) => ({ '.ant-scroll-number-custom-component': { visibility: count === 0 ? 'hidden' : undefined, }, })); const Pulse = styled.div({ cursor: 'pointer', background: theme.warningColor, opacity: 0.6, borderRadius: 20, height: 12, width: 12, ':hover': { opacity: `1 !important`, background: theme.errorColor, animationPlayState: 'paused', }, });