/** * Copyright (c) Facebook, Inc. and its 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 {SearchableProps, FlipperBasePlugin, FlipperPlugin} from 'flipper'; import {Logger} from './fb-interfaces/Logger'; import { Searchable, Button, ButtonGroup, FlexBox, FlexColumn, FlexRow, Glyph, ContextMenu, styled, colors, } from 'flipper'; import {FlipperDevicePlugin} from './plugin'; import {connect} from 'react-redux'; import React, {Component, Fragment} from 'react'; import {clipboard} from 'electron'; import { PluginNotification, updatePluginBlacklist, updateCategoryBlacklist, } from './reducers/notifications'; import {selectPlugin} from './reducers/connections'; import {State as StoreState} from './reducers/index'; import textContent from './utils/textContent'; import createPaste from './fb-stubs/createPaste'; import {getPluginTitle} from './utils/pluginUtils'; type OwnProps = { onClear: () => void; selectedID: string | null | undefined; logger: Logger; } & Partial; type StateFromProps = { activeNotifications: Array; invalidatedNotifications: Array; blacklistedPlugins: Array; blacklistedCategories: Array; devicePlugins: Map; clientPlugins: Map; }; type DispatchFromProps = { selectPlugin: (payload: { selectedPlugin: string | null; selectedApp: string | null; deepLinkPayload: string | null; }) => any; updatePluginBlacklist: (blacklist: Array) => any; updateCategoryBlacklist: (blacklist: Array) => any; }; type Props = OwnProps & StateFromProps & DispatchFromProps; type State = { selectedNotification: string | null | undefined; }; const Content = styled(FlexColumn)({ padding: '0 10px', backgroundColor: colors.light02, overflow: 'scroll', flexGrow: 1, }); const Heading = styled(FlexBox)({ display: 'block', alignItems: 'center', marginTop: 15, marginBottom: 5, color: colors.macOSSidebarSectionTitle, fontSize: 11, fontWeight: 500, textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', flexShrink: 0, }); const NoContent = styled(FlexColumn)({ justifyContent: 'center', alignItems: 'center', textAlign: 'center', flexGrow: 1, fontWeight: 500, lineHeight: 2.5, color: colors.light30, }); class NotificationsTable extends Component { contextMenuItems = [{label: 'Clear all', click: this.props.onClear}]; state: State = { selectedNotification: this.props.selectedID, }; componentDidUpdate(prevProps: Props & SearchableProps) { if (this.props.filters.length !== prevProps.filters.length) { this.props.updatePluginBlacklist( this.props.filters .filter(f => f.type === 'exclude' && f.key.toLowerCase() === 'plugin') .map(f => String(f.value)), ); this.props.updateCategoryBlacklist( this.props.filters .filter( f => f.type === 'exclude' && f.key.toLowerCase() === 'category', ) .map(f => String(f.value)), ); } if ( this.props.selectedID && prevProps.selectedID !== this.props.selectedID ) { this.setState({ selectedNotification: this.props.selectedID, }); } } onHidePlugin = (pluginId: string) => { // add filter to searchbar this.props.addFilter({ value: pluginId, type: 'exclude', key: 'plugin', }); this.props.updatePluginBlacklist( this.props.blacklistedPlugins.concat(pluginId), ); }; onHideCategory = (category: string) => { // add filter to searchbar this.props.addFilter({ value: category, type: 'exclude', key: 'category', }); this.props.updatePluginBlacklist( this.props.blacklistedCategories.concat(category), ); }; getFilter = (): ((n: PluginNotification) => boolean) => ( n: PluginNotification, ) => { const searchTerm = this.props.searchTerm.toLowerCase(); // filter plugins const blacklistedPlugins = new Set( this.props.blacklistedPlugins.map(p => p.toLowerCase()), ); if (blacklistedPlugins.has(n.pluginId.toLowerCase())) { return false; } // filter categories const {category} = n.notification; if (category) { const blacklistedCategories = new Set( this.props.blacklistedCategories.map(p => p.toLowerCase()), ); if (blacklistedCategories.has(category.toLowerCase())) { return false; } } if (searchTerm.length === 0) { return true; } else if (n.notification.title.toLowerCase().indexOf(searchTerm) > -1) { return true; } else if ( typeof n.notification.message === 'string' && n.notification.message.toLowerCase().indexOf(searchTerm) > -1 ) { return true; } return false; }; getPlugin = (id: string) => this.props.clientPlugins.get(id) || this.props.devicePlugins.get(id); render() { const activeNotifications = this.props.activeNotifications .filter(this.getFilter()) .map((n: PluginNotification) => { const {category} = n.notification; return ( this.setState({selectedNotification: n.notification.id}) } onClear={this.props.onClear} onHidePlugin={() => this.onHidePlugin(n.pluginId)} onHideCategory={ category ? () => this.onHideCategory(category) : undefined } selectPlugin={this.props.selectPlugin} logger={this.props.logger} /> ); }) .reverse(); const invalidatedNotifications = this.props.invalidatedNotifications .filter(this.getFilter()) .map((n: PluginNotification) => ( )) .reverse(); return ( {activeNotifications.length > 0 && ( Active notifications {activeNotifications} )} {invalidatedNotifications.length > 0 && ( Past notifications {invalidatedNotifications} )} {activeNotifications.length + invalidatedNotifications.length === 0 && ( No Notifications )} ); } } export const ConnectedNotificationsTable = connect< StateFromProps, DispatchFromProps, OwnProps, StoreState >( ({ notifications: { activeNotifications, invalidatedNotifications, blacklistedPlugins, blacklistedCategories, }, plugins: {devicePlugins, clientPlugins}, }) => ({ activeNotifications, invalidatedNotifications, blacklistedPlugins, blacklistedCategories, devicePlugins, clientPlugins, }), { updatePluginBlacklist, updateCategoryBlacklist, selectPlugin, }, )(Searchable(NotificationsTable)); const shadow = ( props: {isSelected?: boolean; inactive?: boolean}, _hover?: boolean, ) => { if (props.inactive) { return `inset 0 0 0 1px ${colors.light10}`; } const shadow = ['1px 1px 5px rgba(0,0,0,0.1)']; if (props.isSelected) { shadow.push(`inset 0 0 0 2px ${colors.macOSTitleBarIconSelected}`); } return shadow.join(','); }; const SEVERITY_COLOR_MAP = { warning: colors.yellow, error: colors.red, }; type NotificationBoxProps = { inactive?: boolean; isSelected?: boolean; severity: keyof typeof SEVERITY_COLOR_MAP; }; const NotificationBox = styled(FlexRow)(props => ({ backgroundColor: props.inactive ? 'transparent' : colors.white, opacity: props.inactive ? 0.5 : 1, alignItems: 'flex-start', borderRadius: 5, padding: 10, flexShrink: 0, overflow: 'hidden', position: 'relative', marginBottom: 10, boxShadow: shadow(props), '::before': { content: '""', display: !props.inactive && !props.isSelected ? 'block' : 'none', position: 'absolute', left: 0, top: 0, bottom: 0, width: 3, backgroundColor: SEVERITY_COLOR_MAP[props.severity] || colors.info, }, ':hover': { boxShadow: shadow(props, true), '& > *': { opacity: 1, }, }, })); const Title = styled.div({ minWidth: 150, color: colors.light80, flexShrink: 0, marginBottom: 6, fontWeight: 500, lineHeight: 1, fontSize: '1.1em', }); const NotificationContent = styled(FlexColumn)<{isSelected?: boolean}>( props => ({ marginLeft: 6, marginRight: 10, flexGrow: 1, overflow: 'hidden', maxHeight: props.isSelected ? 'none' : 56, lineHeight: 1.4, color: props.isSelected ? colors.light50 : colors.light30, }), ); const Actions = styled(FlexRow)({ alignItems: 'center', justifyContent: 'space-between', color: colors.light20, marginTop: 12, borderTop: `1px solid ${colors.light05}`, paddingTop: 8, }); const NotificationButton = styled.div({ border: `1px solid ${colors.light20}`, color: colors.light50, borderRadius: 4, textAlign: 'center', padding: 4, width: 80, marginBottom: 4, opacity: 0, transition: '0.15s opacity', '[data-role="notification"]:hover &': { opacity: 0.5, }, ':last-child': { marginBottom: 0, }, '[data-role="notification"] &:hover': { opacity: 1, }, }); type ItemProps = { onHighlight?: () => any; onHidePlugin?: () => any; onHideCategory?: () => any; onClear?: () => any; isSelected?: boolean; inactive?: boolean; selectPlugin?: (payload: { selectedPlugin: string | null; selectedApp: string | null; deepLinkPayload: string | null; }) => any; logger?: Logger; plugin: typeof FlipperBasePlugin | null | undefined; }; type ItemState = { reportedNotHelpful: boolean; }; class NotificationItem extends Component< ItemProps & PluginNotification, ItemState > { constructor(props: ItemProps & PluginNotification) { super(props); const items = []; if (props.onHidePlugin && props.plugin) { items.push({ label: `Hide ${getPluginTitle(props.plugin)} plugin`, click: this.props.onHidePlugin, }); } if (props.onHideCategory) { items.push({ label: 'Hide Similar', click: this.props.onHideCategory, }); } items.push( {label: 'Copy', click: this.copy}, {label: 'Create Paste', click: this.createPaste}, ); this.contextMenuItems = items; } state = {reportedNotHelpful: false}; contextMenuItems: Array<{label: string; click: (() => any) | undefined}>; deepLinkButton = React.createRef(); createPaste = () => { createPaste(this.getContent()); }; copy = () => clipboard.writeText(this.getContent()); getContent = (): string => [ this.props.notification.timestamp, `[${this.props.notification.severity}] ${this.props.notification.title}`, this.props.notification.action, this.props.notification.category, textContent(this.props.notification.message), ] .filter(Boolean) .join('\n'); openDeeplink = () => { const {notification, pluginId, client} = this.props; if (this.props.selectPlugin && notification.action) { this.props.selectPlugin({ selectedPlugin: pluginId, selectedApp: client, deepLinkPayload: notification.action, }); } }; reportNotUseful = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (this.props.logger) { this.props.logger.track( 'usage', 'notification-not-useful', this.props.notification, ); } this.setState({reportedNotHelpful: true}); }; onHide = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (this.props.onHideCategory) { this.props.onHideCategory(); } else if (this.props.onHidePlugin) { this.props.onHidePlugin(); } }; render() { const { notification, isSelected, inactive, onHidePlugin, onHideCategory, plugin, } = this.props; const {action} = notification; return ( > data-role="notification" component={NotificationBox} severity={notification.severity} onClick={this.props.onHighlight} isSelected={isSelected} inactive={inactive} items={this.contextMenuItems}> {notification.title} {notification.message} {!inactive && isSelected && plugin && (action || onHidePlugin || onHideCategory) && ( {action && ( )} {onHideCategory && ( )} {onHidePlugin && ( )} {notification.timestamp ? new Date(notification.timestamp).toTimeString() : ''} )} {action && !inactive && !isSelected && ( {action && ( Open )} {this.state.reportedNotHelpful ? ( Hide ) : ( Not helpful )} )} ); } }