From dced4bbc96597fc7edfe327125cae974b059de26 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 26 Jan 2024 13:16:30 +0200 Subject: [PATCH] feat: add support for unread count in document title Signed-off-by: Heikki Hellgren --- plugins/notifications/api-report.md | 13 ++++ plugins/notifications/config.d.ts | 31 +++++++++ plugins/notifications/package.json | 6 +- .../NotificationsSideBarItem.tsx | 66 +++++++++---------- plugins/notifications/src/hooks/index.ts | 2 + .../src/hooks/useTitleCounter.ts | 60 +++++++++++++++++ .../src/hooks/useWebNotifications.ts | 54 +++++++++++++++ 7 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 plugins/notifications/config.d.ts create mode 100644 plugins/notifications/src/hooks/useTitleCounter.ts create mode 100644 plugins/notifications/src/hooks/useWebNotifications.ts diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index ebb146a48f..efe9ba000e 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -121,5 +121,18 @@ export function useNotificationsApi( value: T; }; +// @public (undocumented) +export function useTitleCounter(): { + setNotificationCount: (newCount: number) => void; +}; + +// @public (undocumented) +export function useWebNotifications(): { + sendWebNotification: (options: { + title: string; + description: string; + }) => Notification | null; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/notifications/config.d.ts b/plugins/notifications/config.d.ts new file mode 100644 index 0000000000..0ae7ea2a1c --- /dev/null +++ b/plugins/notifications/config.d.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** @visibility frontend */ + notifications?: { + /** + * Enable or disable the Web Notification API for notifications, defaults to false + * @visibility frontend + */ + enableWebNotifications?: boolean; + /** + * Enable or disable the title override to show notification count, defaults to true + * @visibility frontend + */ + enableTitleCounter?: boolean; + }; +} diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index 57f0b38e82..f5a2f9199a 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -51,6 +51,8 @@ "msw": "^1.0.0" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx index 6dfa0ebb75..f3034f6b26 100644 --- a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -13,36 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useNotificationsApi } from '../../hooks'; import { SidebarItem } from '@backstage/core-components'; import NotificationsIcon from '@material-ui/icons/Notifications'; -import { useRouteRef } from '@backstage/core-plugin-api'; +import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { rootRouteRef } from '../../routes'; import { useSignal } from '@backstage/plugin-signals-react'; +import { useWebNotifications } from '../../hooks/useWebNotifications'; +import { useTitleCounter } from '../../hooks/useTitleCounter'; /** @public */ export const NotificationsSidebarItem = () => { const { loading, error, value, retry } = useNotificationsApi(api => api.getStatus(), ); + const config = useApi(configApiRef); const [unreadCount, setUnreadCount] = React.useState(0); - const [webNotificationPermission, setWebNotificationPermission] = - React.useState('default'); const notificationsRoute = useRouteRef(rootRouteRef); const { lastSignal } = useSignal('notifications'); - const [webNotifications, setWebNotifications] = React.useState< - Notification[] - >([]); + const { sendWebNotification } = useWebNotifications(); const [refresh, setRefresh] = React.useState(false); - - useEffect(() => { - if ('Notification' in window && webNotificationPermission === 'default') { - window.Notification.requestPermission().then(permission => { - setWebNotificationPermission(permission); - }); - } - }, [webNotificationPermission]); + const webNotificationsEnabled = useMemo( + () => + config.getOptionalBoolean('notifications.enableWebNotifications') ?? + false, + [config], + ); + const titleCounterEnabled = useMemo( + () => config.getOptionalString('notifications.enableTitleCounter') ?? true, + [config], + ); + const { setNotificationCount } = useTitleCounter(); useEffect(() => { if (refresh) { @@ -54,37 +56,35 @@ export const NotificationsSidebarItem = () => { useEffect(() => { if (lastSignal && lastSignal.action === 'refresh') { if ( - webNotificationPermission === 'granted' && + webNotificationsEnabled && 'title' in lastSignal && 'description' in lastSignal && 'link' in lastSignal ) { - const notification = new Notification(lastSignal.title as string, { - body: lastSignal.description as string, + const notification = sendWebNotification({ + title: lastSignal.title as string, + description: lastSignal.description as string, }); - notification.onclick = function (event) { - event.preventDefault(); - notification.close(); - window.open(lastSignal.link as string, '_blank'); - }; - setWebNotifications(prev => [...prev, notification]); + if (notification) { + notification.onclick = event => { + event.preventDefault(); + notification.close(); + window.open(lastSignal.link as string, '_blank'); + }; + } } setRefresh(true); } - }, [lastSignal, webNotificationPermission]); + }, [lastSignal, sendWebNotification, webNotificationsEnabled]); useEffect(() => { if (!loading && !error && value) { setUnreadCount(value.unread); + if (titleCounterEnabled) { + setNotificationCount(value.unread); + } } - }, [loading, error, value]); - - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - webNotifications.forEach(n => n.close()); - setWebNotifications([]); - } - }); + }, [loading, error, value, titleCounterEnabled, setNotificationCount]); // TODO: Figure out if the count can be added to hasNotifications return ( diff --git a/plugins/notifications/src/hooks/index.ts b/plugins/notifications/src/hooks/index.ts index 2613596f72..516901aa04 100644 --- a/plugins/notifications/src/hooks/index.ts +++ b/plugins/notifications/src/hooks/index.ts @@ -14,3 +14,5 @@ * limitations under the License. */ export * from './useNotificationsApi'; +export * from './useWebNotifications'; +export * from './useTitleCounter'; diff --git a/plugins/notifications/src/hooks/useTitleCounter.ts b/plugins/notifications/src/hooks/useTitleCounter.ts new file mode 100644 index 0000000000..d4ba22b946 --- /dev/null +++ b/plugins/notifications/src/hooks/useTitleCounter.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useCallback, useEffect, useState } from 'react'; + +/** @public */ +export function useTitleCounter() { + const [title, setTitle] = useState(document.title); + const [count, setCount] = useState(0); + + const getPrefix = (value: number) => { + return value === 0 ? '' : `(${value}) `; + }; + + const cleanTitle = (currentTitle: string) => { + return currentTitle.replace(/^\(\d+\)\s/, ''); + }; + + useEffect(() => { + document.title = title; + }, [title]); + + useEffect(() => { + const baseTitle = cleanTitle(title); + setTitle(`${getPrefix(count)}${baseTitle}`); + return () => { + document.title = cleanTitle(title); + }; + }, [title, count]); + + const titleElement = document.querySelector('title'); + if (titleElement) { + new MutationObserver(() => { + setTitle(document.title); + }).observe(titleElement, { + subtree: true, + characterData: true, + childList: true, + }); + } + + const setNotificationCount = useCallback( + (newCount: number) => setCount(newCount), + [], + ); + + return { setNotificationCount }; +} diff --git a/plugins/notifications/src/hooks/useWebNotifications.ts b/plugins/notifications/src/hooks/useWebNotifications.ts new file mode 100644 index 0000000000..7e34172a97 --- /dev/null +++ b/plugins/notifications/src/hooks/useWebNotifications.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useCallback, useEffect, useState } from 'react'; + +/** @public */ +export function useWebNotifications() { + const [webNotificationPermission, setWebNotificationPermission] = + useState('default'); + const [webNotifications, setWebNotifications] = useState([]); + + useEffect(() => { + if ('Notification' in window && webNotificationPermission === 'default') { + window.Notification.requestPermission().then(permission => { + setWebNotificationPermission(permission); + }); + } + }, [webNotificationPermission]); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + webNotifications.forEach(n => n.close()); + setWebNotifications([]); + } + }); + + const sendWebNotification = useCallback( + (options: { title: string; description: string }) => { + if (webNotificationPermission !== 'granted') { + return null; + } + + const notification = new Notification(options.title, { + body: options.description, + }); + return notification; + }, + [webNotificationPermission], + ); + + return { sendWebNotification }; +}