Rework notifications (#1465)

* Rework notifications

- dispatchUINotification returns a promise that contains the notification id
- notifications use i18n instead of plain text

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>

* Reformat code

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-08-31 22:21:29 +02:00 committed by GitHub
parent 808601eaba
commit 553e9f8ead
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 154 additions and 105 deletions

View file

@ -3,6 +3,10 @@
"slogan": "The best platform to write and share markdown.", "slogan": "The best platform to write and share markdown.",
"title": "Collaborative markdown notes" "title": "Collaborative markdown notes"
}, },
"notificationTest": {
"title": "Test",
"content": "It works!"
},
"renderer": { "renderer": {
"highlightCode": { "highlightCode": {
"copyCode": "Copy code to clipboard" "copyCode": "Copy code to clipboard"

View file

@ -6,8 +6,7 @@
import { Editor, Hint, Hints, Pos } from 'codemirror' import { Editor, Hint, Hints, Pos } from 'codemirror'
import { findWordAtCursor, generateHintListByPrefix, Hinter } from './index' import { findWordAtCursor, generateHintListByPrefix, Hinter } from './index'
import { DEFAULT_DURATION_IN_SECONDS, dispatchUiNotification } from '../../../../redux/ui-notifications/methods' import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import i18n from 'i18next'
type highlightJsImport = typeof import('../../../common/hljs/hljs') type highlightJsImport = typeof import('../../../common/hljs/hljs')
@ -22,12 +21,7 @@ const loadHighlightJs = async (): Promise<highlightJsImport | null> => {
try { try {
return await import('../../../common/hljs/hljs') return await import('../../../common/hljs/hljs')
} catch (error) { } catch (error) {
dispatchUiNotification( showErrorNotification('common.errorWhileLoadingLibrary', { name: 'highlight.js' })(error as Error)
i18n.t('common.errorOccurred'),
i18n.t('common.errorWhileLoadingLibrary', { name: 'highlight.js' }),
DEFAULT_DURATION_IN_SECONDS,
'exclamation-circle'
)
console.error("can't load highlight js", error) console.error("can't load highlight js", error)
return null return null
} }

View file

@ -15,7 +15,7 @@ import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ className, hide }) => { export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ className, hide }) => {
const { t } = useTranslation() useTranslation()
const { id } = useParams<EditorPagePathParams>() const { id } = useParams<EditorPagePathParams>()
const history = useApplicationState((state) => state.history) const history = useApplicationState((state) => state.history)
@ -28,8 +28,8 @@ export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ class
}, [id, history]) }, [id, history])
const onPinClicked = useCallback(() => { const onPinClicked = useCallback(() => {
toggleHistoryEntryPinning(id).catch(showErrorNotification(t('landing.history.error.updateEntry.text'))) toggleHistoryEntryPinning(id).catch(showErrorNotification('landing.history.error.updateEntry.text'))
}, [id, t]) }, [id])
return ( return (
<SidebarButton <SidebarButton

View file

@ -5,23 +5,29 @@
*/ */
import { useEffect } from 'react' import { useEffect } from 'react'
import { DEFAULT_DURATION_IN_SECONDS, dispatchUiNotification } from '../../redux/ui-notifications/methods' import { dispatchUiNotification } from '../../redux/ui-notifications/methods'
const localStorageKey = 'dontshowtestnotification' const localStorageKey = 'dontshowtestnotification'
/**
* Spawns a notification to test the system. Only for tech demo show case.
*/
export const useNotificationTest = (): void => { export const useNotificationTest = (): void => {
useEffect(() => { useEffect(() => {
if (window.localStorage.getItem(localStorageKey)) { if (window.localStorage.getItem(localStorageKey)) {
return return
} }
console.debug('[Notifications] Dispatched test notification') console.debug('[Notifications] Dispatched test notification')
dispatchUiNotification('Notification-Test!', 'It Works!', DEFAULT_DURATION_IN_SECONDS, 'info-circle', [ void dispatchUiNotification('notificationTest.title', 'notificationTest.content', {
{ icon: 'info-circle',
label: "Don't show again", buttons: [
onClick: () => { {
window.localStorage.setItem(localStorageKey, '1') label: "Don't show again",
onClick: () => {
window.localStorage.setItem(localStorageKey, '1')
}
} }
} ]
]) })
}, []) }, [])
} }

View file

@ -40,33 +40,23 @@ export interface HistoryEntriesProps {
} }
export const HistoryContent: React.FC<HistoryContentProps> = ({ viewState, entries }) => { export const HistoryContent: React.FC<HistoryContentProps> = ({ viewState, entries }) => {
const { t } = useTranslation() useTranslation()
const [pageIndex, setPageIndex] = useState(0) const [pageIndex, setPageIndex] = useState(0)
const [lastPageIndex, setLastPageIndex] = useState(0) const [lastPageIndex, setLastPageIndex] = useState(0)
const onPinClick = useCallback( const onPinClick = useCallback((noteId: string) => {
(noteId: string) => { toggleHistoryEntryPinning(noteId).catch(showErrorNotification('landing.history.error.updateEntry.text'))
toggleHistoryEntryPinning(noteId).catch(showErrorNotification(t('landing.history.error.updateEntry.text'))) }, [])
},
[t]
)
const onDeleteClick = useCallback( const onDeleteClick = useCallback((noteId: string) => {
(noteId: string) => { deleteNote(noteId)
deleteNote(noteId) .then(() => removeHistoryEntry(noteId))
.then(() => removeHistoryEntry(noteId)) .catch(showErrorNotification('landing.history.error.deleteNote.text'))
.catch(showErrorNotification(t('landing.history.error.deleteNote.text'))) }, [])
},
[t]
)
const onRemoveClick = useCallback( const onRemoveClick = useCallback((noteId: string) => {
(noteId: string) => { removeHistoryEntry(noteId).catch(showErrorNotification('landing.history.error.deleteEntry.text'))
removeHistoryEntry(noteId).catch(showErrorNotification(t('landing.history.error.deleteEntry.text'))) }, [])
},
[t]
)
if (entries.length === 0) { if (entries.length === 0) {
return ( return (

View file

@ -16,7 +16,7 @@ import { showErrorNotification } from '../../redux/ui-notifications/methods'
import { useApplicationState } from '../../hooks/common/use-application-state' import { useApplicationState } from '../../hooks/common/use-application-state'
export const HistoryPage: React.FC = () => { export const HistoryPage: React.FC = () => {
const { t } = useTranslation() useTranslation()
const allEntries = useApplicationState((state) => state.history) const allEntries = useApplicationState((state) => state.history)
const [toolbarState, setToolbarState] = useState<HistoryToolbarState>(initToolbarState) const [toolbarState, setToolbarState] = useState<HistoryToolbarState>(initToolbarState)
@ -27,8 +27,8 @@ export const HistoryPage: React.FC = () => {
) )
useEffect(() => { useEffect(() => {
refreshHistoryState().catch(showErrorNotification(t('landing.history.error.getHistory.text'))) refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text'))
}, [t]) }, [])
return ( return (
<Fragment> <Fragment>

View file

@ -21,11 +21,11 @@ export const ClearHistoryButton: React.FC = () => {
const onConfirm = useCallback(() => { const onConfirm = useCallback(() => {
deleteAllHistoryEntries().catch((error) => { deleteAllHistoryEntries().catch((error) => {
showErrorNotification(t('landing.history.error.deleteEntry.text'))(error) showErrorNotification('landing.history.error.deleteEntry.text')(error)
refreshHistoryState().catch(showErrorNotification(t('landing.history.error.getHistory.text'))) refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text'))
}) })
handleClose() handleClose()
}, [t]) }, [])
return ( return (
<Fragment> <Fragment>

View file

@ -116,8 +116,8 @@ export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange
) )
const refreshHistory = useCallback(() => { const refreshHistory = useCallback(() => {
refreshHistoryState().catch(showErrorNotification(t('landing.history.error.getHistory.text'))) refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text'))
}, [t]) }, [])
const onUploadAllToRemote = useCallback(() => { const onUploadAllToRemote = useCallback(() => {
if (!userExists) { if (!userExists) {
@ -128,7 +128,7 @@ export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange
.map((entry) => entry.identifier) .map((entry) => entry.identifier)
historyEntries.forEach((entry) => (entry.origin = HistoryEntryOrigin.REMOTE)) historyEntries.forEach((entry) => (entry.origin = HistoryEntryOrigin.REMOTE))
importHistoryEntries(historyEntries).catch((error) => { importHistoryEntries(historyEntries).catch((error) => {
showErrorNotification(t('landing.history.error.setHistory.text'))(error) showErrorNotification('landing.history.error.setHistory.text')(error)
historyEntries.forEach((entry) => { historyEntries.forEach((entry) => {
if (localEntries.includes(entry.identifier)) { if (localEntries.includes(entry.identifier)) {
entry.origin = HistoryEntryOrigin.LOCAL entry.origin = HistoryEntryOrigin.LOCAL
@ -137,7 +137,7 @@ export const HistoryToolbar: React.FC<HistoryToolbarProps> = ({ onSettingsChange
setHistoryEntries(historyEntries) setHistoryEntries(historyEntries)
refreshHistory() refreshHistory()
}) })
}, [userExists, historyEntries, t, refreshHistory]) }, [userExists, historyEntries, refreshHistory])
useEffect(() => { useEffect(() => {
const newState: HistoryToolbarState = { const newState: HistoryToolbarState = {

View file

@ -42,11 +42,11 @@ export const ImportHistoryButton: React.FC = () => {
(entries: HistoryEntry[]): void => { (entries: HistoryEntry[]): void => {
entries.forEach((entry) => (entry.origin = userExists ? HistoryEntryOrigin.REMOTE : HistoryEntryOrigin.LOCAL)) entries.forEach((entry) => (entry.origin = userExists ? HistoryEntryOrigin.REMOTE : HistoryEntryOrigin.LOCAL))
importHistoryEntries(mergeHistoryEntries(historyState, entries)).catch((error) => { importHistoryEntries(mergeHistoryEntries(historyState, entries)).catch((error) => {
showErrorNotification(t('landing.history.error.setHistory.text'))(error) showErrorNotification('landing.history.error.setHistory.text')(error)
refreshHistoryState().catch(showErrorNotification(t('landing.history.error.getHistory.text'))) refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text'))
}) })
}, },
[historyState, userExists, t] [historyState, userExists]
) )
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {

View file

@ -11,6 +11,7 @@ import { ForkAwesomeIcon } from '../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../common/show-if/show-if' import { ShowIf } from '../common/show-if/show-if'
import { IconName } from '../common/fork-awesome/types' import { IconName } from '../common/fork-awesome/types'
import { dismissUiNotification } from '../../redux/ui-notifications/methods' import { dismissUiNotification } from '../../redux/ui-notifications/methods'
import { Trans, useTranslation } from 'react-i18next'
const STEPS_PER_SECOND = 10 const STEPS_PER_SECOND = 10
@ -19,8 +20,10 @@ export interface UiNotificationProps extends UiNotification {
} }
export const UiNotificationToast: React.FC<UiNotificationProps> = ({ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
title, titleI18nKey,
content, contentI18nKey,
titleI18nOptions,
contentI18nOptions,
date, date,
icon, icon,
dismissed, dismissed,
@ -28,6 +31,7 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
durationInSecond, durationInSecond,
buttons buttons
}) => { }) => {
const { t } = useTranslation()
const [eta, setEta] = useState<number>() const [eta, setEta] = useState<number>()
const interval = useRef<NodeJS.Timeout | undefined>(undefined) const interval = useRef<NodeJS.Timeout | undefined>(undefined)
@ -89,15 +93,17 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
) )
const contentDom = useMemo(() => { const contentDom = useMemo(() => {
return content.split('\n').map((value, lineNumber) => { return t(contentI18nKey, contentI18nOptions)
return ( .split('\n')
<Fragment key={lineNumber}> .map((value, lineNumber) => {
{value} return (
<br /> <Fragment key={lineNumber}>
</Fragment> {value}
) <br />
}) </Fragment>
}, [content]) )
})
}, [contentI18nKey, contentI18nOptions, t])
return ( return (
<Toast show={!dismissed && eta !== undefined} onClose={dismissThisNotification}> <Toast show={!dismissed && eta !== undefined} onClose={dismissThisNotification}>
@ -106,7 +112,7 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
<ShowIf condition={!!icon}> <ShowIf condition={!!icon}>
<ForkAwesomeIcon icon={icon as IconName} fixedWidth={true} className={'mr-1'} /> <ForkAwesomeIcon icon={icon as IconName} fixedWidth={true} className={'mr-1'} />
</ShowIf> </ShowIf>
{title} <Trans i18nKey={titleI18nKey} tOptions={titleI18nOptions} />
</strong> </strong>
<small>{date.toRelative({ style: 'short' })}</small> <small>{date.toRelative({ style: 'short' })}</small>
</Toast.Header> </Toast.Header>

View file

@ -4,40 +4,56 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import i18n from 'i18next' import i18n, { TOptions } from 'i18next'
import { store } from '../index' import { store } from '../index'
import { import { DismissUiNotificationAction, DispatchOptions, UiNotificationActionType } from './types'
DismissUiNotificationAction,
DispatchUiNotificationAction,
UiNotificationActionType,
UiNotificationButton
} from './types'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { IconName } from '../../components/common/fork-awesome/types'
export const DEFAULT_DURATION_IN_SECONDS = 10 export const DEFAULT_DURATION_IN_SECONDS = 10
export const dispatchUiNotification = ( /**
title: string, * Dispatches a new UI Notification into the global application state.
content: string, *
durationInSecond = DEFAULT_DURATION_IN_SECONDS, * @param titleI18nKey I18n key used to show the localized title
icon?: IconName, * @param contentI18nKey I18n key used to show the localized content
buttons?: UiNotificationButton[] * @param icon The icon in the upper left corner
): void => { * @param durationInSecond Show duration of the notification. If omitted then a {@link DEFAULT_DURATION_IN_SECONDS default value} will be used.
store.dispatch({ * @param buttons A array of actions that are shown in the notification
type: UiNotificationActionType.DISPATCH_NOTIFICATION, * @param contentI18nOptions Options to configure the translation of the title. (e.g. variables)
notification: { * @param titleI18nOptions Options to configure the translation of the content. (e.g. variables)
title, * @return a promise that resolves as soon as the notification id available.
content, */
date: DateTime.now(), export const dispatchUiNotification = async (
dismissed: false, titleI18nKey: string,
icon, contentI18nKey: string,
durationInSecond, { icon, durationInSecond, buttons, contentI18nOptions, titleI18nOptions }: Partial<DispatchOptions>
buttons: buttons ): Promise<number> => {
} return new Promise((resolve) => {
} as DispatchUiNotificationAction) store.dispatch({
type: UiNotificationActionType.DISPATCH_NOTIFICATION,
notificationIdCallback: (notificationId: number) => {
resolve(notificationId)
},
notification: {
titleI18nKey,
contentI18nKey,
date: DateTime.now(),
dismissed: false,
titleI18nOptions: titleI18nOptions ?? {},
contentI18nOptions: contentI18nOptions ?? {},
durationInSecond: durationInSecond ?? DEFAULT_DURATION_IN_SECONDS,
buttons: buttons ?? [],
icon: icon
}
})
})
} }
/**
* Dismisses a notification. It won't be removed from the global application state but hidden.
*
* @param notificationId The id of the notification to dismissed. Can be obtained from the returned promise of {@link dispatchUiNotification}
*/
export const dismissUiNotification = (notificationId: number): void => { export const dismissUiNotification = (notificationId: number): void => {
store.dispatch({ store.dispatch({
type: UiNotificationActionType.DISMISS_NOTIFICATION, type: UiNotificationActionType.DISMISS_NOTIFICATION,
@ -45,9 +61,18 @@ export const dismissUiNotification = (notificationId: number): void => {
} as DismissUiNotificationAction) } as DismissUiNotificationAction)
} }
/**
* Dispatches an notification that is specialized for errors.
*
* @param messageI18nKey i18n key for the message
* @param messageI18nOptions i18n options for the message
*/
export const showErrorNotification = export const showErrorNotification =
(message: string) => (messageI18nKey: string, messageI18nOptions?: TOptions | string) =>
(error: Error): void => { (error: Error): void => {
console.error(message, error) console.error(i18n.t(messageI18nKey, messageI18nOptions), error)
dispatchUiNotification(i18n.t('common.errorOccurred'), message, DEFAULT_DURATION_IN_SECONDS, 'exclamation-triangle') void dispatchUiNotification('common.errorOccurred', messageI18nKey, {
contentI18nOptions: messageI18nOptions,
icon: 'exclamation-triangle'
})
} }

View file

@ -5,7 +5,7 @@
*/ */
import { Reducer } from 'redux' import { Reducer } from 'redux'
import { UiNotificationActions, UiNotificationActionType, UiNotificationState } from './types' import { UiNotification, UiNotificationActions, UiNotificationActionType, UiNotificationState } from './types'
export const UiNotificationReducer: Reducer<UiNotificationState, UiNotificationActions> = ( export const UiNotificationReducer: Reducer<UiNotificationState, UiNotificationActions> = (
state: UiNotificationState = [], state: UiNotificationState = [],
@ -13,7 +13,7 @@ export const UiNotificationReducer: Reducer<UiNotificationState, UiNotificationA
) => { ) => {
switch (action.type) { switch (action.type) {
case UiNotificationActionType.DISPATCH_NOTIFICATION: case UiNotificationActionType.DISPATCH_NOTIFICATION:
return state.concat(action.notification) return addNewNotification(state, action.notification, action.notificationIdCallback)
case UiNotificationActionType.DISMISS_NOTIFICATION: case UiNotificationActionType.DISMISS_NOTIFICATION:
return dismissNotification(state, action.notificationId) return dismissNotification(state, action.notificationId)
default: default:
@ -21,6 +21,23 @@ export const UiNotificationReducer: Reducer<UiNotificationState, UiNotificationA
} }
} }
/**
* Creates a new {@link UiNotificationState notification state} by appending the given {@link UiNotification}.
* @param state The current ui notification state
* @param notification The new notification
* @param notificationIdCallback This callback is executed with the id of the new notification
* @return The new {@link UiNotificationState notification state}
*/
const addNewNotification = (
state: UiNotificationState,
notification: UiNotification,
notificationIdCallback: (notificationId: number) => void
): UiNotificationState => {
const newState = [...state, notification]
notificationIdCallback(newState.length - 1)
return newState
}
const dismissNotification = ( const dismissNotification = (
notificationState: UiNotificationState, notificationState: UiNotificationState,
notificationIndex: number notificationIndex: number

View file

@ -7,6 +7,7 @@
import { Action } from 'redux' import { Action } from 'redux'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { IconName } from '../../components/common/fork-awesome/types' import { IconName } from '../../components/common/fork-awesome/types'
import { TOptions } from 'i18next'
export enum UiNotificationActionType { export enum UiNotificationActionType {
DISPATCH_NOTIFICATION = 'notification/dispatch', DISPATCH_NOTIFICATION = 'notification/dispatch',
@ -18,14 +19,19 @@ export interface UiNotificationButton {
onClick: () => void onClick: () => void
} }
export interface UiNotification { export interface DispatchOptions {
title: string titleI18nOptions: TOptions | string
date: DateTime contentI18nOptions: TOptions | string
content: string
dismissed: boolean
icon?: IconName
durationInSecond: number durationInSecond: number
buttons?: UiNotificationButton[] icon?: IconName
buttons: UiNotificationButton[]
}
export interface UiNotification extends DispatchOptions {
titleI18nKey: string
contentI18nKey: string
date: DateTime
dismissed: boolean
} }
export type UiNotificationActions = DispatchUiNotificationAction | DismissUiNotificationAction export type UiNotificationActions = DispatchUiNotificationAction | DismissUiNotificationAction
@ -33,6 +39,7 @@ export type UiNotificationActions = DispatchUiNotificationAction | DismissUiNoti
export interface DispatchUiNotificationAction extends Action<UiNotificationActionType> { export interface DispatchUiNotificationAction extends Action<UiNotificationActionType> {
type: UiNotificationActionType.DISPATCH_NOTIFICATION type: UiNotificationActionType.DISPATCH_NOTIFICATION
notification: UiNotification notification: UiNotification
notificationIdCallback: (notificationId: number) => void
} }
export interface DismissUiNotificationAction extends Action<UiNotificationActionType> { export interface DismissUiNotificationAction extends Action<UiNotificationActionType> {