enhancement(notifications): remove from state after dismissal

This commit changes the implementation from an array to a Map,
as that makes accessing a notification for dismissal or removal
more performant.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2023-10-14 03:37:30 +02:00
parent ea57f5bb3b
commit 909f45bc3f
4 changed files with 54 additions and 34 deletions

View file

@ -10,10 +10,11 @@ import { UiNotifications } from './ui-notifications'
import type { TOptions } from 'i18next' import type { TOptions } from 'i18next'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react' import React, { createContext, useCallback, useContext, useMemo } from 'react'
import { ExclamationTriangle as IconExclamationTriangle } from 'react-bootstrap-icons' import { ExclamationTriangle as IconExclamationTriangle } from 'react-bootstrap-icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { useMap } from 'react-use'
const log = new Logger('Notifications') const log = new Logger('Notifications')
@ -27,6 +28,7 @@ interface UiNotificationContext {
showErrorNotification: (messageI18nKey: string, messageI18nOptions?: TOptions) => (error: Error) => void showErrorNotification: (messageI18nKey: string, messageI18nOptions?: TOptions) => (error: Error) => void
dismissNotification: (notificationUuid: string) => void dismissNotification: (notificationUuid: string) => void
pruneNotification: (notificationUuid: string) => void
} }
/** /**
@ -50,7 +52,9 @@ const uiNotificationContext = createContext<UiNotificationContext | undefined>(u
*/ */
export const UiNotificationBoundary: React.FC<PropsWithChildren> = ({ children }) => { export const UiNotificationBoundary: React.FC<PropsWithChildren> = ({ children }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [uiNotifications, setUiNotifications] = useState<UiNotification[]>([]) const [uiNotifications, { set: setUiNotification, remove: removeUiNotification, get: getUiNotification }] = useMap<
Record<string, UiNotification>
>({})
const dispatchUiNotification = useCallback( const dispatchUiNotification = useCallback(
( (
@ -58,23 +62,21 @@ export const UiNotificationBoundary: React.FC<PropsWithChildren> = ({ children }
contentI18nKey: string, contentI18nKey: string,
{ icon, durationInSecond, buttons, contentI18nOptions, titleI18nOptions }: Partial<DispatchOptions> { icon, durationInSecond, buttons, contentI18nOptions, titleI18nOptions }: Partial<DispatchOptions>
) => { ) => {
setUiNotifications((oldState) => [ const notificationUuid = uuid()
...oldState, setUiNotification(notificationUuid, {
{ titleI18nKey,
titleI18nKey, contentI18nKey,
contentI18nKey, createdAtTimestamp: DateTime.now().toSeconds(),
createdAtTimestamp: DateTime.now().toSeconds(), dismissed: false,
dismissed: false, titleI18nOptions: titleI18nOptions ?? {},
titleI18nOptions: titleI18nOptions ?? {}, contentI18nOptions: contentI18nOptions ?? {},
contentI18nOptions: contentI18nOptions ?? {}, durationInSecond: durationInSecond ?? DEFAULT_DURATION_IN_SECONDS,
durationInSecond: durationInSecond ?? DEFAULT_DURATION_IN_SECONDS, buttons: buttons ?? [],
buttons: buttons ?? [], icon: icon,
icon: icon, uuid: notificationUuid
uuid: uuid() })
}
])
}, },
[] [setUiNotification]
) )
const showErrorNotification = useCallback( const showErrorNotification = useCallback(
@ -89,24 +91,35 @@ export const UiNotificationBoundary: React.FC<PropsWithChildren> = ({ children }
[dispatchUiNotification, t] [dispatchUiNotification, t]
) )
const dismissNotification = useCallback((notificationUuid: string): void => { const dismissNotification = useCallback(
setUiNotifications((old) => { (notificationUuid: string): void => {
const found = old.find((notification) => notification.uuid === notificationUuid) const entry = getUiNotification(notificationUuid)
if (found === undefined) { if (!entry) {
return old return
} }
return old.filter((value) => value.uuid !== notificationUuid).concat({ ...found, dismissed: true }) setUiNotification(notificationUuid, { ...entry, dismissed: true })
}) },
}, []) [setUiNotification, getUiNotification]
)
const pruneNotification = useCallback(
(notificationUuid: string): void => {
if (!uiNotifications[notificationUuid]) {
return
}
removeUiNotification(notificationUuid)
},
[uiNotifications, removeUiNotification]
)
const context = useMemo(() => { const context = useMemo(() => {
return { return {
dispatchUiNotification: dispatchUiNotification, dispatchUiNotification: dispatchUiNotification,
showErrorNotification: showErrorNotification, showErrorNotification: showErrorNotification,
dismissNotification: dismissNotification dismissNotification: dismissNotification,
pruneNotification: pruneNotification
} }
}, [dismissNotification, dispatchUiNotification, showErrorNotification]) }, [dismissNotification, dispatchUiNotification, showErrorNotification, pruneNotification])
return ( return (
<uiNotificationContext.Provider value={context}> <uiNotificationContext.Provider value={context}>
<UiNotifications notifications={uiNotifications} /> <UiNotifications notifications={uiNotifications} />

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -31,13 +31,18 @@ export interface UiNotificationProps {
export const UiNotificationToast: React.FC<UiNotificationProps> = ({ notification }) => { export const UiNotificationToast: React.FC<UiNotificationProps> = ({ notification }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [remainingSteps, setRemainingSteps] = useState<number>(() => notification.durationInSecond * STEPS_PER_SECOND) const [remainingSteps, setRemainingSteps] = useState<number>(() => notification.durationInSecond * STEPS_PER_SECOND)
const { dismissNotification } = useUiNotifications() const { dismissNotification, pruneNotification } = useUiNotifications()
const dismissNow = useCallback(() => { const dismissNow = useCallback(() => {
log.debug(`Dismiss notification ${notification.uuid} immediately`) log.debug(`Dismiss notification ${notification.uuid} immediately`)
setRemainingSteps(0) setRemainingSteps(0)
}, [notification.uuid]) }, [notification.uuid])
const prune = useCallback(() => {
log.debug(`Prune notification ${notification.uuid} from state`)
pruneNotification(notification.uuid)
}, [pruneNotification, notification.uuid])
useEffectOnce(() => { useEffectOnce(() => {
log.debug(`Show notification ${notification.uuid}`) log.debug(`Show notification ${notification.uuid}`)
}) })
@ -97,6 +102,7 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({ notificatio
className={styles.toast} className={styles.toast}
show={!notification.dismissed} show={!notification.dismissed}
onClose={dismissNow} onClose={dismissNow}
onExited={prune}
{...cypressId('notification-toast')}> {...cypressId('notification-toast')}>
<Toast.Header> <Toast.Header>
<strong className='me-auto'> <strong className='me-auto'>

View file

@ -9,7 +9,7 @@ import { UiNotificationToast } from './ui-notification-toast'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
export interface UiNotificationsProps { export interface UiNotificationsProps {
notifications: UiNotification[] notifications: Record<string, UiNotification>
} }
/** /**
@ -19,7 +19,7 @@ export interface UiNotificationsProps {
*/ */
export const UiNotifications: React.FC<UiNotificationsProps> = ({ notifications }) => { export const UiNotifications: React.FC<UiNotificationsProps> = ({ notifications }) => {
const notificationElements = useMemo(() => { const notificationElements = useMemo(() => {
return notifications return Object.values(notifications)
.sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp) .sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp)
.map((notification) => <UiNotificationToast key={notification.uuid} notification={notification} />) .map((notification) => <UiNotificationToast key={notification.uuid} notification={notification} />)
}, [notifications]) }, [notifications])

View file

@ -14,6 +14,7 @@ export const mockUiNotifications = () => {
jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({ jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
showErrorNotification: jest.fn(), showErrorNotification: jest.fn(),
dismissNotification: jest.fn(), dismissNotification: jest.fn(),
dispatchUiNotification: jest.fn() dispatchUiNotification: jest.fn(),
pruneNotification: jest.fn()
}) })
} }