diff --git a/desktop/app/src/sandy-chrome/LeftRail.tsx b/desktop/app/src/sandy-chrome/LeftRail.tsx
index 4d350f9f4..d8b808832 100644
--- a/desktop/app/src/sandy-chrome/LeftRail.tsx
+++ b/desktop/app/src/sandy-chrome/LeftRail.tsx
@@ -54,6 +54,8 @@ import {getInstance} from '../fb-stubs/Logger';
import {isStaticViewActive} from '../chrome/mainsidebar/sidebarUtils';
import {getUser} from '../fb-stubs/user';
import {SandyRatingButton} from '../chrome/RatingButton';
+import {filterNotifications} from './notification/notificationUtils';
+import {useMemoize} from '../utils/useMemoize';
const LeftRailButtonElem = styled(Button)<{kind?: 'small'}>(({kind}) => ({
width: kind === 'small' ? 32 : 36,
@@ -213,15 +215,18 @@ function NotificationButton({
toplevelSelection,
setToplevelSelection,
}: ToplevelProps) {
- const notificationCount = useStore(
- (state) => state.notifications.activeNotifications.length,
- );
+ const notifications = useStore((state) => state.notifications);
+ const activeNotifications = useMemoize(filterNotifications, [
+ notifications.activeNotifications,
+ notifications.blacklistedPlugins,
+ notifications.blacklistedCategories,
+ ]);
return (
}
title="Notifications"
selected={toplevelSelection === 'notification'}
- count={notificationCount}
+ count={activeNotifications.length}
onClick={() => setToplevelSelection('notification')}
/>
);
diff --git a/desktop/app/src/sandy-chrome/notification/Notification.tsx b/desktop/app/src/sandy-chrome/notification/Notification.tsx
index 6dda2f2d3..91e0228c0 100644
--- a/desktop/app/src/sandy-chrome/notification/Notification.tsx
+++ b/desktop/app/src/sandy-chrome/notification/Notification.tsx
@@ -10,7 +10,7 @@
import React, {useCallback, useMemo, useState} from 'react';
import {Layout, theme} from 'flipper-plugin';
import {styled, Glyph} from '../../ui';
-import {Input, Typography, Button, Collapse} from 'antd';
+import {Input, Typography, Button, Collapse, Dropdown, Menu} from 'antd';
import {
DownOutlined,
UpOutlined,
@@ -18,6 +18,7 @@ import {
ExclamationCircleOutlined,
SettingOutlined,
DeleteOutlined,
+ EllipsisOutlined,
} from '@ant-design/icons';
import {LeftSidebar, SidebarTitle} from '../LeftSidebar';
import {Notification as NotificationData} from '../../plugin';
@@ -25,10 +26,18 @@ import {useStore, useDispatch} from '../../utils/useStore';
import {ClientQuery} from '../../Client';
import {deconstructClientId} from '../../utils/clientUtils';
import {selectPlugin} from '../../reducers/connections';
-import {clearAllNotifications} from '../../reducers/notifications';
+import {
+ clearAllNotifications,
+ updateCategoryBlacklist,
+ updatePluginBlacklist,
+} from '../../reducers/notifications';
+import {filterNotifications} from './notificationUtils';
+import {useMemoize} from '../../utils/useMemoize';
type NotificationExtra = {
onOpen: () => void;
+ onHideSimilar: (() => void) | null;
+ onHidePlugin: () => void;
clientName: string | undefined;
appName: string | undefined;
pluginName: string;
@@ -49,6 +58,11 @@ const CollapseContainer = styled.div({
},
});
+const ItemContainer = styled(Layout.Container)({
+ '.notification-item-action': {visibility: 'hidden'},
+ ':hover': {'.notification-item-action': {visibility: 'visible'}},
+});
+
function DetailCollapse({detail}: {detail: string | React.ReactNode}) {
const detailView =
typeof detail === 'string' ? (
@@ -92,6 +106,8 @@ function DetailCollapse({detail}: {detail: string | React.ReactNode}) {
function NotificationEntry({notification}: {notification: PluginNotification}) {
const {
onOpen,
+ onHideSimilar,
+ onHidePlugin,
message,
title,
clientName,
@@ -100,17 +116,43 @@ function NotificationEntry({notification}: {notification: PluginNotification}) {
iconName,
} = notification;
+ const actions = useMemo(
+ () => (
+
+
+ {onHideSimilar && (
+
+ Hide Similar
+
+ )}
+
+ Hide {pluginName}
+
+
+ }>
+ } />
+
+
+ ),
+ [onHideSimilar, onHidePlugin, pluginName],
+ );
+
const icon = iconName ? (
) : (
);
return (
-
-
- {icon}
- {pluginName}
-
+
+
+
+ {icon}
+ {pluginName}
+
+ {actions}
+
{title}
@@ -123,7 +165,7 @@ function NotificationEntry({notification}: {notification: PluginNotification}) {
Open {pluginName}
-
+
);
}
@@ -171,44 +213,51 @@ export function Notification() {
[clientPlugins, devicePlugins],
);
- const activeNotifications = useStore(
- (state) => state.notifications.activeNotifications,
- );
+ const notifications = useStore((state) => state.notifications);
+ const activeNotifications = useMemoize(filterNotifications, [
+ notifications.activeNotifications,
+ notifications.blacklistedPlugins,
+ notifications.blacklistedCategories,
+ ]);
const displayedNotifications: Array = useMemo(
() =>
- activeNotifications
- .filter(
- (noti) =>
- noti.notification.title
- .toLocaleLowerCase()
- .includes(searchString.toLocaleLowerCase()) ||
- (typeof noti.notification.message === 'string'
- ? noti.notification.message
- .toLocaleLowerCase()
- .includes(searchString.toLocaleLowerCase())
- : false),
- )
- .map((noti) => {
- const plugin = getPlugin(noti.pluginId);
- const client = getClientQuery(noti.client);
- return {
- ...noti.notification,
- onOpen: () =>
- dispatch(
- selectPlugin({
- selectedPlugin: noti.pluginId,
- selectedApp: noti.client,
- deepLinkPayload: noti.notification.action,
- }),
- ),
- clientName: client?.device_id,
- appName: client?.app,
- pluginName: plugin?.title ?? noti.pluginId,
- iconName: plugin?.icon,
- };
- }),
- [activeNotifications, getPlugin, getClientQuery, searchString, dispatch],
+ activeNotifications.map((noti) => {
+ const plugin = getPlugin(noti.pluginId);
+ const client = getClientQuery(noti.client);
+ return {
+ ...noti.notification,
+ onOpen: () =>
+ dispatch(
+ selectPlugin({
+ selectedPlugin: noti.pluginId,
+ selectedApp: noti.client,
+ deepLinkPayload: noti.notification.action,
+ }),
+ ),
+ onHideSimilar: noti.notification.category
+ ? () =>
+ dispatch(
+ updateCategoryBlacklist([
+ ...notifications.blacklistedCategories,
+ noti.notification.category!,
+ ]),
+ )
+ : null,
+ onHidePlugin: () =>
+ dispatch(
+ updatePluginBlacklist([
+ ...notifications.blacklistedPlugins,
+ noti.pluginId,
+ ]),
+ ),
+ clientName: client?.device_id,
+ appName: client?.app,
+ pluginName: plugin?.title ?? noti.pluginId,
+ iconName: plugin?.icon,
+ };
+ }),
+ [activeNotifications, notifications, getPlugin, getClientQuery, dispatch],
);
const actions = (
diff --git a/desktop/app/src/sandy-chrome/notification/__tests__/notificationUtils.spec.tsx b/desktop/app/src/sandy-chrome/notification/__tests__/notificationUtils.spec.tsx
new file mode 100644
index 000000000..fcdf8cc8c
--- /dev/null
+++ b/desktop/app/src/sandy-chrome/notification/__tests__/notificationUtils.spec.tsx
@@ -0,0 +1,111 @@
+/**
+ * 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 {PluginNotification} from '../../../reducers/notifications';
+import {filterNotifications} from '../notificationUtils';
+
+const PLUGIN_COUNT = 3;
+const CLIENT_COUNT = 2;
+const CATEGORY_LABEL_COUNT = 2 * PLUGIN_COUNT * CLIENT_COUNT;
+const CATEGORY_LABEL = 'some category';
+
+const unfilteredNotifications: Array = [...Array(20)].map(
+ (_, idx) => ({
+ notification: {
+ id: `${idx}`,
+ title: `title ${idx}`,
+ message: `message ${idx}`,
+ severity: 'warning',
+ category: idx % CATEGORY_LABEL_COUNT ? undefined : CATEGORY_LABEL,
+ },
+ pluginId: `plugin_${idx % PLUGIN_COUNT}`,
+ client: `client_${idx % CLIENT_COUNT}`,
+ }),
+);
+
+test('Filter nothing', async () => {
+ const filteredNotifications = filterNotifications(
+ unfilteredNotifications.slice(),
+ );
+ expect(filteredNotifications.length).toBe(unfilteredNotifications.length);
+ expect(filteredNotifications).toEqual(unfilteredNotifications);
+});
+
+test('Filter by single pluginId', async () => {
+ const blockedPluginId = 'plugin_0';
+ const filteredNotifications = filterNotifications(
+ unfilteredNotifications.slice(),
+ [blockedPluginId],
+ );
+ const expectedNotification = unfilteredNotifications
+ .slice()
+ .filter((_, idx) => idx % PLUGIN_COUNT);
+
+ expect(filteredNotifications.length).toBe(expectedNotification.length);
+ expect(filteredNotifications).toEqual(expectedNotification);
+});
+
+test('Filter by multiple pluginId', async () => {
+ const blockedPluginIds = ['plugin_1', 'plugin_2'];
+ const filteredNotifications = filterNotifications(
+ unfilteredNotifications.slice(),
+ blockedPluginIds,
+ );
+ const expectedNotification = unfilteredNotifications
+ .slice()
+ .filter((_, idx) => !(idx % PLUGIN_COUNT));
+
+ expect(filteredNotifications.length).toBe(expectedNotification.length);
+ expect(filteredNotifications).toEqual(expectedNotification);
+});
+
+test('Filter by category', async () => {
+ const blockedCategory = CATEGORY_LABEL;
+ const filteredNotifications = filterNotifications(
+ unfilteredNotifications.slice(),
+ [],
+ [blockedCategory],
+ );
+ const expectedNotification = unfilteredNotifications
+ .slice()
+ .filter((_, idx) => idx % CATEGORY_LABEL_COUNT);
+
+ expect(filteredNotifications.length).toBe(expectedNotification.length);
+ expect(filteredNotifications).toEqual(expectedNotification);
+});
+
+test('Filter by pluginId and category', async () => {
+ const blockedCategory = CATEGORY_LABEL;
+ const blockedPluginId = 'plugin_1';
+ const filteredNotifications = filterNotifications(
+ unfilteredNotifications.slice(),
+ [blockedPluginId],
+ [blockedCategory],
+ );
+ const expectedNotification = unfilteredNotifications
+ .slice()
+ .filter((_, idx) => idx % CATEGORY_LABEL_COUNT && idx % PLUGIN_COUNT !== 1);
+
+ expect(filteredNotifications.length).toBe(expectedNotification.length);
+ expect(filteredNotifications).toEqual(expectedNotification);
+});
+
+test('Filter by string searching', async () => {
+ const searchString = 'age 5';
+ const filteredNotifications = filterNotifications(
+ unfilteredNotifications.slice(),
+ [],
+ [],
+ searchString,
+ );
+ const expectedNotification = [unfilteredNotifications[5]];
+
+ expect(filteredNotifications.length).toBe(expectedNotification.length);
+ expect(filteredNotifications).toEqual(expectedNotification);
+});
diff --git a/desktop/app/src/sandy-chrome/notification/notificationUtils.tsx b/desktop/app/src/sandy-chrome/notification/notificationUtils.tsx
new file mode 100644
index 000000000..6f790b91e
--- /dev/null
+++ b/desktop/app/src/sandy-chrome/notification/notificationUtils.tsx
@@ -0,0 +1,39 @@
+/**
+ * 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 {PluginNotification} from '../../reducers/notifications';
+
+export function filterNotifications(
+ notifications: Array,
+ blocklistedPlugins?: Array,
+ blocklistedCategories?: Array,
+ searchString?: string,
+): Array {
+ return notifications
+ .filter((noti) =>
+ blocklistedPlugins ? !blocklistedPlugins.includes(noti.pluginId) : true,
+ )
+ .filter((noti) =>
+ blocklistedCategories && noti.notification.category
+ ? !blocklistedCategories?.includes(noti.notification.category)
+ : true,
+ )
+ .filter((noti) =>
+ searchString
+ ? noti.notification.title
+ .toLocaleLowerCase()
+ .includes(searchString.toLocaleLowerCase()) ||
+ (typeof noti.notification.message === 'string'
+ ? noti.notification.message
+ .toLocaleLowerCase()
+ .includes(searchString.toLocaleLowerCase())
+ : false)
+ : true,
+ );
+}