diff --git a/src/PluginContainer.js b/src/PluginContainer.js index 0fa7bdf2a..ff894eee5 100644 --- a/src/PluginContainer.js +++ b/src/PluginContainer.js @@ -6,7 +6,6 @@ */ import type {FlipperPlugin, FlipperBasePlugin} from './plugin.js'; import type LogManager from './fb-stubs/Logger'; -import type Client from './Client.js'; import type BaseDevice from './devices/BaseDevice.js'; import type {Props as PluginProps} from './plugin'; @@ -20,10 +19,10 @@ import { styled, } from 'flipper'; import React from 'react'; +import Client from './Client.js'; import {connect} from 'react-redux'; import {setPluginState} from './reducers/pluginStates.js'; import {setActiveNotifications} from './reducers/notifications.js'; -import type {NotificationSet} from './plugin.js'; import {devicePlugins} from './device-plugins/index.js'; import plugins from './plugins/index.js'; import {activateMenuItems} from './MenuBar.js'; @@ -54,10 +53,7 @@ type Props = { pluginKey: string, state: Object, }) => void, - setActiveNotifications: ({ - pluginId: string, - notifications: NotificationSet, - }) => void, + deepLinkPayload: ?string, }; type State = { @@ -130,7 +126,7 @@ class PluginContainer extends Component { }; render() { - const {pluginStates, setPluginState, setActiveNotifications} = this.props; + const {pluginStates, setPluginState} = this.props; const {activePlugin, pluginKey, target} = this.state; if (!activePlugin || !target) { @@ -142,12 +138,8 @@ class PluginContainer extends Component { logger: this.props.logger, persistedState: pluginStates[pluginKey] || {}, setPersistedState: state => setPluginState({pluginKey, state}), - setActiveNotifications: notifications => - setActiveNotifications({ - pluginId: pluginKey, - notifications: notifications, - }), target, + deepLinkPayload: this.props.deepLinkPayload, ref: this.refChanged, }; @@ -171,7 +163,13 @@ class PluginContainer extends Component { export default connect( ({ application: {rightSidebarVisible, rightSidebarAvailable}, - connections: {selectedPlugin, selectedDevice, selectedApp, clients}, + connections: { + selectedPlugin, + selectedDevice, + selectedApp, + clients, + deepLinkPayload, + }, pluginStates, }) => ({ selectedPlugin, @@ -179,6 +177,7 @@ export default connect( pluginStates, selectedApp, clients, + deepLinkPayload, }), { setPluginState, diff --git a/src/chrome/MainSidebar.js b/src/chrome/MainSidebar.js index 0e35fbfe4..96a781cb9 100644 --- a/src/chrome/MainSidebar.js +++ b/src/chrome/MainSidebar.js @@ -12,6 +12,7 @@ import type { } from '../plugin.js'; import type BaseDevice from '../devices/BaseDevice.js'; import type Client from '../Client.js'; +import type {PluginNotification} from '../reducers/notifications'; import { Component, @@ -22,6 +23,7 @@ import { Text, Glyph, styled, + GK, } from 'flipper'; import React from 'react'; import {devicePlugins} from '../device-plugins/index.js'; @@ -69,12 +71,31 @@ const PluginShape = styled(FlexBox)(({backgroundColor}) => ({ alignItems: 'center', })); -const PluginName = styled(Text)({ +const PluginName = styled(Text)(props => ({ minWidth: 0, textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', -}); + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + flexGrow: 1, + '::after': { + fontSize: 12, + display: props.count ? 'inline-block' : 'none', + padding: '0 8px', + lineHeight: '17px', + height: 17, + alignSelf: 'center', + content: `"${props.count}"`, + borderRadius: '999em', + color: props.isActive ? colors.macOSTitleBarIconSelected : colors.white, + backgroundColor: props.isActive + ? colors.white + : colors.macOSTitleBarIconSelected, + fontWeight: 500, + }, +})); function PluginIcon({ isActive, @@ -83,7 +104,7 @@ function PluginIcon({ color, }: { isActive: boolean, - backgroundColor: string, + backgroundColor?: string, name: string, color: string, }) { @@ -145,6 +166,8 @@ type MainSidebarProps = {| selectedApp: ?string, }) => void, clients: Array, + activeNotifications: Array, + blacklistedPlugins: Array, |}; class MainSidebar extends Component { @@ -155,6 +178,7 @@ class MainSidebar extends Component { selectedApp, selectPlugin, windowIsFocused, + activeNotifications, } = this.props; let {clients} = this.props; @@ -175,6 +199,11 @@ class MainSidebar extends Component { ) .sort((a, b) => (a.query.app || '').localeCompare(b.query.app)); + let blacklistedPlugins = new Set(this.props.blacklistedPlugins); + const notifications = activeNotifications.filter( + (n: PluginNotification) => !blacklistedPlugins.has(n.pluginId), + ); + return ( { backgroundColor={ process.platform === 'darwin' && windowIsFocused ? 'transparent' : '' }> + {GK.get('flipper_notifications') && ( + + selectPlugin({ + selectedPlugin: 'notifications', + selectedApp: null, + }) + }> + 0 ? 'bell' : 'bell-null'} + isActive={selectedPlugin === 'notifications'} + /> + + Notifications + + + )} + {selectedDevice && ( + {selectedDevice.title} + )} {selectedDevice && devicePlugins .filter(selectedDevice.supportsPlugin) @@ -242,7 +295,10 @@ export default connect( ({ application: {windowIsFocused}, connections: {selectedDevice, selectedPlugin, selectedApp, clients}, + notifications: {activeNotifications, blacklistedPlugins}, }) => ({ + blacklistedPlugins, + activeNotifications, windowIsFocused, selectedDevice, selectedPlugin, diff --git a/src/device-plugins/index.js b/src/device-plugins/index.js index 7c0420cde..919016cd3 100644 --- a/src/device-plugins/index.js +++ b/src/device-plugins/index.js @@ -10,6 +10,7 @@ import type {FlipperDevicePlugin} from '../plugin.js'; import {GK} from 'flipper'; import logs from './logs/index.js'; import cpu from './cpu/index.js'; +import notifications from './notifications/index.js'; const plugins: Array>> = [logs]; @@ -17,4 +18,8 @@ if (GK.get('sonar_uiperf')) { plugins.push(cpu); } +if (GK.get('flipper_notifications')) { + plugins.push(notifications); +} + export const devicePlugins = plugins; diff --git a/src/device-plugins/notifications/index.js b/src/device-plugins/notifications/index.js new file mode 100644 index 000000000..c5d44a1c4 --- /dev/null +++ b/src/device-plugins/notifications/index.js @@ -0,0 +1,441 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type {SearchableProps, FlipperPlugin} from 'flipper'; +import type {PluginNotification} from '../../reducers/notifications'; +import {selectPlugin} from '../../reducers/connections'; + +import { + FlipperDevicePlugin, + Searchable, + Button, + FlexBox, + FlexColumn, + FlexRow, + Glyph, + ContextMenu, + styled, + colors, +} from 'flipper'; +import {connect} from 'react-redux'; +import React, {Component, Fragment} from 'react'; +import plugins from '../../plugins/index'; +import {clipboard} from 'electron'; +import PropTypes from 'prop-types'; +import { + clearAllNotifications, + updatePluginBlacklist, +} from '../../reducers/notifications'; +import {createPaste, textContent} from '../../utils/index'; + +export default class Notifications extends FlipperDevicePlugin<{}> { + static id = 'notifications'; + static title = 'Notifications'; + static icon = 'bell'; + static keyboardActions = ['clear']; + + static contextTypes = { + store: PropTypes.object.isRequired, + }; + + onKeyboardAction = (action: string) => { + if (action === 'clear') { + this.onClear(); + } + }; + + onClear = () => { + this.context.store.dispatch(clearAllNotifications()); + }; + + render() { + return ( + ({ + value, + invertible: false, + type: 'exclude', + key: 'plugin', + }))} + actions={ + + + + } + /> + ); + } +} + +type Props = {| + ...SearchableProps, + activeNotifications: Array, + invalidatedNotifications: Array, + blacklistedPlugins: Array, + onClear: () => void, + updatePluginBlacklist: (blacklist: Array) => mixed, + selectPlugin: ({ + selectedPlugin: ?string, + selectedApp: ?string, + deepLinkPayload?: ?string, + }) => mixed, +|}; + +type State = {| + selectedNotification: ?string, +|}; + +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 { + state = { + selectedNotification: null, + }; + + contextMenuItems = [{label: 'Clear all', click: this.props.onClear}]; + + componentDidUpdate(prevProps: Props) { + 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)), + ); + } + } + + onHide = (pluginId: string) => { + // add filter to searchbar + this.props.addFilter({ + value: pluginId, + type: 'exclude', + key: 'plugin', + invertible: false, + }); + this.props.updatePluginBlacklist( + this.props.blacklistedPlugins.concat(pluginId), + ); + }; + + getFilter = (): ((n: PluginNotification) => boolean) => ( + n: PluginNotification, + ) => { + const searchTerm = this.props.searchTerm.toLowerCase(); + const blacklist = new Set( + this.props.blacklistedPlugins.map(p => p.toLowerCase()), + ); + if (blacklist.has(n.pluginId.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; + }; + + render() { + const activeNotifications = this.props.activeNotifications + .filter(this.getFilter()) + .map((n: PluginNotification) => ( + + this.setState({selectedNotification: n.notification.id}) + } + onClear={this.props.onClear} + onHide={() => this.onHide(n.pluginId)} + selectPlugin={this.props.selectPlugin} + /> + )); + + const invalidatedNotifications = this.props.invalidatedNotifications + .filter(this.getFilter()) + .map((n: PluginNotification) => ( + + )); + + return ( + + {activeNotifications.length > 0 && ( + + Active notifications + {activeNotifications} + + )} + {invalidatedNotifications.length > 0 && ( + + Past notifications + {invalidatedNotifications} + + )} + {activeNotifications.length + invalidatedNotifications.length === 0 && ( + + + No Notifications + + )} + + ); + } +} + +const ConnectedNotificationsTable = connect( + ({ + notifications: { + activeNotifications, + invalidatedNotifications, + blacklistedPlugins, + }, + }) => ({ + activeNotifications, + invalidatedNotifications, + blacklistedPlugins, + }), + { + updatePluginBlacklist, + selectPlugin, + }, +)(Searchable(NotificationsTable)); + +const shadow = (props, hover) => { + if (props.inactive) { + return `inset 0 0 0 1px ${colors.light10}`; + } + let 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, +}; + +const NotificationBox = styled(FlexColumn)(props => ({ + backgroundColor: props.inactive ? 'transparent' : colors.white, + opacity: props.inactive ? 0.5 : 1, + 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(FlexRow)({ + fontWeight: 500, + marginBottom: 5, + alignItems: 'center', + fontSize: '1.1em', +}); + +const Chevron = styled(Glyph)({ + position: 'absolute', + right: 10, + top: '50%', + transform: 'translateY(-50%)', + opacity: 0.5, +}); + +type ItemProps = { + ...PluginNotification, + onClick?: () => mixed, + onHide?: () => mixed, + isSelected?: boolean, + inactive?: boolean, + selectPlugin?: ({ + selectedPlugin: ?string, + selectedApp: ?string, + deepLinkPayload?: ?string, + }) => mixed, +}; + +class NotificationItem extends Component { + constructor(props: ItemProps) { + super(props); + const plugin = plugins.find(p => p.id === props.pluginId); + + const items = []; + if (props.onHide && plugin) { + items.push({ + label: `Hide ${plugin.title} plugin`, + click: this.props.onHide, + }); + } + items.push( + {label: 'Copy', click: this.copy}, + {label: 'Create Paste', click: this.createPaste}, + ); + + this.contextMenuItems = items; + this.plugin = plugin; + } + + plugin: ?Class>; + contextMenuItems; + deepLinkButton = React.createRef(); + + onClick = (e: MouseEvent) => { + if ( + this.props.notification.action && + e.target === this.deepLinkButton.current + ) { + this.openDeeplink(); + } else if (this.props.onClick) { + this.props.onClick(); + } + }; + + 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, + }); + } + }; + + render() { + const {notification, isSelected, inactive, onHide} = this.props; + const {action} = notification; + + return ( + + + <Glyph name={this.plugin?.icon || 'bell'} size={12} /> + <span> </span> + {notification.title} + + {notification.message} + {action && + !inactive && + !isSelected && ( + + )} + {!inactive && + isSelected && + this.plugin && + (action || onHide) && ( + + {action && ( + + )} + {onHide && ( + + )} + + )} + + ); + } +} diff --git a/src/plugin.js b/src/plugin.js index e53fbfdc3..b53ddc703 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -24,23 +24,12 @@ export type PluginClient = {| type PluginTarget = BaseDevice | Client; -export type Notification = {| - title: string, - message: string, - severity: 'warning' | 'error', - timestamp?: number, - category?: string, - action?: string, -|}; - -export type NotificationSet = {[id: string]: Notification}; - export type Props = { logger: Logger, persistedState: T, setPersistedState: (state: $Shape) => void, - setActiveNotifications: NotificationSet => void, target: PluginTarget, + deepLinkPayload: ?string, }; export class FlipperBasePlugin< @@ -75,9 +64,6 @@ export class FlipperBasePlugin< // methods to be overriden by plugins init(): void {} teardown(): void {} - computeNotifications(props: Props<*>, state: State): NotificationSet { - return {}; - } // methods to be overridden by subclasses _init(): void {} _teardown(): void {} @@ -97,10 +83,6 @@ export class FlipperBasePlugin< throw new TypeError(`Reducer ${actionData.type} isn't a function`); } } - - componentDidUpdate(props: Props<*>, state: State): void { - props.setActiveNotifications(this.computeNotifications(props, state)); - } } export class FlipperDevicePlugin extends FlipperBasePlugin< diff --git a/src/plugins/network/index.js b/src/plugins/network/index.js index eb04f6bb3..2d98bd6bd 100644 --- a/src/plugins/network/index.js +++ b/src/plugins/network/index.js @@ -7,7 +7,6 @@ import type {TableHighlightedRows, TableRows, TableBodyRow} from 'flipper'; -import type {NotificationSet} from '../../plugin'; import { ContextMenu, FlexColumn, @@ -154,7 +153,7 @@ export default class extends FlipperPlugin { }; computeNotifications(props: *, state: State) { - const notifications: NotificationSet = {}; + const notifications = {}; const persistedState = props.persistedState; for (const response in persistedState.responses) { const status = persistedState.responses[response].status; diff --git a/src/reducers/notifications.js b/src/reducers/notifications.js index bde2d10ab..3f9b9d3e4 100644 --- a/src/reducers/notifications.js +++ b/src/reducers/notifications.js @@ -4,35 +4,59 @@ * LICENSE file in the root directory of this source tree. * @format */ +import type {Node} from 'react'; -import type {Notification, NotificationSet} from '../plugin'; - -type PluginNotification = {| +export type Notification = {| id: string, + title: string, + message: Node, + severity: 'warning' | 'error', + timestamp?: number, + category?: string, + action?: string, +|}; + +export type PluginNotification = {| notification: Notification, pluginId: string, + client: ?string, |}; export type State = { activeNotifications: Array, invalidatedNotifications: Array, + blacklistedPlugins: Array, + clearedNotifications: Set, }; type ActiveNotificationsAction = { type: 'SET_ACTIVE_NOTIFICATIONS', - payload: NotificationSet, - pluginId: string, + payload: { + notifications: Array, + client: ?string, + pluginId: string, + }, }; type ClearAllAction = { type: 'CLEAR_ALL_NOTIFICATIONS', }; -export type Action = ActiveNotificationsAction | ClearAllAction; +type UpdateBlacklistAction = { + type: 'UPDATE_PLUGIN_BLACKLIST', + payload: Array, +}; + +export type Action = + | ActiveNotificationsAction + | ClearAllAction + | UpdateBlacklistAction; const INITIAL_STATE: State = { activeNotifications: [], invalidatedNotifications: [], + blacklistedPlugins: [], + clearedNotifications: new Set(), }; export default function reducer( @@ -44,8 +68,25 @@ export default function reducer( return activeNotificationsReducer(state, action); } case 'CLEAR_ALL_NOTIFICATIONS': + const markAsCleared = ({ + pluginId, + notification: {id}, + }: PluginNotification) => + state.clearedNotifications.add(`${pluginId}#${id}`); + + state.activeNotifications.forEach(markAsCleared); + state.invalidatedNotifications.forEach(markAsCleared); // Q: Should this actually delete them, or just invalidate them? - return INITIAL_STATE; + return { + ...state, + activeNotifications: [], + invalidatedNotifications: [], + }; + case 'UPDATE_PLUGIN_BLACKLIST': + return { + ...state, + blacklistedPlugins: action.payload, + }; default: return state; } @@ -54,46 +95,52 @@ export default function reducer( function activeNotificationsReducer( state: State, action: ActiveNotificationsAction, -) { - const {payload, pluginId} = action; +): State { + const {payload} = action; const newActiveNotifications = []; const newInactivatedNotifications = state.invalidatedNotifications; + + const newIDs = new Set(payload.notifications.map((n: Notification) => n.id)); + for (const activeNotification of state.activeNotifications) { - if (activeNotification.pluginId !== pluginId) { + if (activeNotification.pluginId !== payload.pluginId) { newActiveNotifications.push(activeNotification); continue; } - if (!payload[activeNotification.id]) { + if (!newIDs.has(activeNotification.notification.id)) { newInactivatedNotifications.push(activeNotification); } } - for (const id in payload) { - const newNotification = { - id, - notification: payload[id], - pluginId, - }; - newActiveNotifications.push(newNotification); - } + payload.notifications + .filter( + ({id}: Notification) => + !state.clearedNotifications.has(`${payload.pluginId}#${id}`), + ) + .forEach((notification: Notification) => { + newActiveNotifications.push({ + pluginId: payload.pluginId, + client: payload.client, + notification, + }); + }); + return { + ...state, activeNotifications: newActiveNotifications, invalidatedNotifications: newInactivatedNotifications, }; } export function setActiveNotifications(payload: { - notifications: { - [id: string]: Notification, - }, + notifications: Array, + client: ?string, pluginId: string, }): Action { - const {notifications, pluginId} = payload; return { type: 'SET_ACTIVE_NOTIFICATIONS', - payload: notifications, - pluginId, + payload, }; } @@ -102,3 +149,12 @@ export function clearAllNotifications(): Action { type: 'CLEAR_ALL_NOTIFICATIONS', }; } + +export function updatePluginBlacklist( + payload: Array, +): UpdateBlacklistAction { + return { + type: 'UPDATE_PLUGIN_BLACKLIST', + payload, + }; +}