Reviewed By: lblasa Differential Revision: D50500690 fbshipit-source-id: 6f739fe25c232ecfe842337af4399681e85f6a13
180 lines
4.8 KiB
TypeScript
180 lines
4.8 KiB
TypeScript
/**
|
|
* 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<typeof createNuxManager>;
|
|
|
|
const storageKey = `FLIPPER_NUX_STATE`;
|
|
|
|
export async function getNuxKey(
|
|
elem: React.ReactNode,
|
|
currentPlugin?: _SandyPluginInstance | _SandyDevicePluginInstance,
|
|
): Promise<string> {
|
|
const hash = await sha256(reactElementToJSXString(elem));
|
|
return `${currentPlugin?.definition.id ?? 'flipper'}:${hash}`;
|
|
}
|
|
|
|
export function createNuxManager() {
|
|
const ticker = createState(0);
|
|
|
|
let readMap: Record<string, boolean> = 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<void> {
|
|
readMap[await getNuxKey(elem, currentPlugin)] = true;
|
|
save();
|
|
},
|
|
async isRead(
|
|
elem: React.ReactNode,
|
|
currentPlugin?: _SandyPluginInstance | _SandyDevicePluginInstance,
|
|
): Promise<boolean> {
|
|
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<NuxManager>(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 (
|
|
<UnanimatedBadge
|
|
count={
|
|
isRead ? (
|
|
0
|
|
) : (
|
|
<Tooltip
|
|
placement={placement}
|
|
color={theme.backgroundWash}
|
|
title={
|
|
<Layout.Container
|
|
center
|
|
gap
|
|
pad
|
|
style={{color: theme.textColorPrimary}}>
|
|
<BulbTwoTone style={{fontSize: 24}} />
|
|
<Text>{title}</Text>
|
|
<Tracked action={'nux:dismiss:' + title.substr(0, 50)}>
|
|
<Button size="small" type="default" onClick={dismiss}>
|
|
Dismiss
|
|
</Button>
|
|
</Tracked>
|
|
</Layout.Container>
|
|
}>
|
|
<Pulse />
|
|
</Tooltip>
|
|
)
|
|
}>
|
|
{children}
|
|
</UnanimatedBadge>
|
|
);
|
|
}
|
|
|
|
// 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',
|
|
},
|
|
});
|