improve: Move notifications from redux into context

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-08-18 23:07:23 +02:00
parent b797f07aa5
commit 03d87f59f8
38 changed files with 362 additions and 376 deletions

View file

@ -11,7 +11,7 @@ import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { removeGroupPermission, setGroupPermission } from '../../../../api/permissions'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface PermissionEntrySpecialGroupProps {
level: AccessLevel
@ -27,6 +27,7 @@ export interface PermissionEntrySpecialGroupProps {
export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupProps> = ({ level, type }) => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const { t } = useTranslation()
const { showErrorNotification } = useUiNotifications()
const onSetEntryReadOnly = useCallback(() => {
setGroupPermission(noteId, type, false)
@ -34,7 +35,7 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, type])
}, [noteId, showErrorNotification, type])
const onSetEntryWriteable = useCallback(() => {
setGroupPermission(noteId, type, true)
@ -42,7 +43,7 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, type])
}, [noteId, showErrorNotification, type])
const onSetEntryDenied = useCallback(() => {
removeGroupPermission(noteId, type)
@ -50,7 +51,7 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, type])
}, [noteId, showErrorNotification, type])
const name = useMemo(() => {
switch (type) {

View file

@ -14,7 +14,7 @@ import { ShowIf } from '../../../common/show-if/show-if'
import { removeUserPermission, setUserPermission } from '../../../../api/permissions'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface PermissionEntryUserProps {
entry: NoteUserPermissionEntry
@ -27,6 +27,7 @@ export interface PermissionEntryUserProps {
*/
export const PermissionEntryUser: React.FC<PermissionEntryUserProps> = ({ entry }) => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const { showErrorNotification } = useUiNotifications()
const onRemoveEntry = useCallback(() => {
removeUserPermission(noteId, entry.username)
@ -34,7 +35,7 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps> = ({ entry
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, entry.username])
}, [noteId, entry.username, showErrorNotification])
const onSetEntryReadOnly = useCallback(() => {
setUserPermission(noteId, entry.username, false)
@ -42,7 +43,7 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps> = ({ entry
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, entry.username])
}, [noteId, entry.username, showErrorNotification])
const onSetEntryWriteable = useCallback(() => {
setUserPermission(noteId, entry.username, true)
@ -50,7 +51,7 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps> = ({ entry
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, entry.username])
}, [noteId, entry.username, showErrorNotification])
const { value, loading, error } = useAsync(async () => {
return await getUser(entry.username)

View file

@ -9,8 +9,8 @@ import { PermissionOwnerChange } from './permission-owner-change'
import { PermissionOwnerInfo } from './permission-owner-info'
import { setNoteOwner } from '../../../../api/permissions'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
/**
* Section in the permissions modal for managing the owner of a note.
@ -18,6 +18,7 @@ import { setNotePermissionsFromServer } from '../../../../redux/note-details/met
export const PermissionSectionOwner: React.FC = () => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const [changeOwner, setChangeOwner] = useState(false)
const { showErrorNotification } = useUiNotifications()
const onSetChangeOwner = useCallback(() => {
setChangeOwner(true)
@ -34,7 +35,7 @@ export const PermissionSectionOwner: React.FC = () => {
setChangeOwner(false)
})
},
[noteId]
[noteId, showErrorNotification]
)
return (

View file

@ -10,7 +10,7 @@ import { PermissionEntryUser } from './permission-entry-user'
import { PermissionAddEntryField } from './permission-add-entry-field'
import { setUserPermission } from '../../../../api/permissions'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
/**
* Section of the permission modal for managing user access to the note.
@ -19,6 +19,7 @@ export const PermissionSectionUsers: React.FC = () => {
useTranslation()
const userPermissions = useApplicationState((state) => state.noteDetails.permissions.sharedToUsers)
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const { showErrorNotification } = useUiNotifications()
const userEntries = useMemo(() => {
return userPermissions.map((entry) => <PermissionEntryUser key={entry.username} entry={entry} />)
@ -32,7 +33,7 @@ export const PermissionSectionUsers: React.FC = () => {
})
.catch(showErrorNotification('editor.modal.permissions.error'))
},
[noteId]
[noteId, showErrorNotification]
)
return (

View file

@ -13,10 +13,10 @@ import { UserAvatar } from '../../../common/user-avatar/user-avatar'
import styles from './revision-list-entry.module.scss'
import type { RevisionMetadata } from '../../../../api/revisions/types'
import { getUserDataForRevision } from './utils'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useAsync } from 'react-use'
import { ShowIf } from '../../../common/show-if/show-if'
import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface RevisionListEntryProps {
active: boolean
@ -33,6 +33,7 @@ export interface RevisionListEntryProps {
*/
export const RevisionListEntry: React.FC<RevisionListEntryProps> = ({ active, onSelect, revision }) => {
useTranslation()
const { showErrorNotification } = useUiNotifications()
const revisionCreationTime = useMemo(() => {
return DateTime.fromISO(revision.createdAt).toFormat('DDDD T')

View file

@ -10,7 +10,7 @@ import { downloadRevision } from './utils'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { getRevision } from '../../../../api/revisions'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface RevisionModalFooterProps {
selectedRevisionId?: number
@ -29,6 +29,7 @@ export const RevisionModalFooter: React.FC<RevisionModalFooterProps & Pick<Modal
}) => {
useTranslation()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const { showErrorNotification } = useUiNotifications()
const onRevertToRevision = useCallback(() => {
// TODO Websocket message handler missing
@ -45,7 +46,7 @@ export const RevisionModalFooter: React.FC<RevisionModalFooterProps & Pick<Modal
downloadRevision(noteIdentifier, revision)
})
.catch(showErrorNotification(''))
}, [noteIdentifier, selectedRevisionId])
}, [noteIdentifier, selectedRevisionId, showErrorNotification])
return (
<Modal.Footer>

View file

@ -16,7 +16,6 @@ import { Sidebar } from './sidebar/sidebar'
import { Splitter } from './splitter/splitter'
import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
import { useEditorModeFromUrl } from './hooks/use-editor-mode-from-url'
import { UiNotifications } from '../notifications/ui-notifications'
import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry'
import { useApplicationState } from '../../hooks/common/use-application-state'
import { EditorDocumentRenderer } from './editor-document-renderer/editor-document-renderer'
@ -128,7 +127,6 @@ export const EditorPageContent: React.FC = () => {
return (
<ChangeEditorContentContextProvider>
<NoteAndAppTitleHead />
<UiNotifications />
<MotdModal />
<div className={'d-flex flex-column vh-100'}>
<AppBar mode={AppBarMode.EDITOR} />

View file

@ -15,7 +15,7 @@ import { replaceInContent } from '../tool-bar/formatters/replace-in-content'
import type { CursorSelection } from '../tool-bar/formatters/types/cursor-selection'
import type { EditorView } from '@codemirror/view'
import type { ContentFormatter } from '../../change-content-context/change-content-context'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
/**
* @param view the codemirror instance that is used to insert the Markdown code
@ -36,36 +36,41 @@ type handleUploadSignature = (
* Provides a callback that uploads a given file and inserts the correct Markdown code into the current editor.
*/
export const useHandleUpload = (): handleUploadSignature => {
return useCallback((view, file, cursorSelection, description, additionalUrlText) => {
const changeContent = (callback: ContentFormatter) => changeEditorContent(view, callback)
if (!file || !supportedMimeTypes.includes(file.type) || !changeContent) {
return
}
const randomId = Math.random().toString(36).slice(7)
const uploadFileInfo = description
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description })
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
const { showErrorNotification } = useUiNotifications()
const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})`
const noteId = getGlobalState().noteDetails.id
changeContent(({ currentSelection }) => {
return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false)
})
uploadFile(noteId, file)
.then(({ url }) => {
const replacement = `![${description ?? file.name ?? ''}](${url}${additionalUrlText ?? ''})`
changeContent(({ markdownContent }) => [
replaceInContent(markdownContent, uploadPlaceholder, replacement),
undefined
])
return useCallback(
(view, file, cursorSelection, description, additionalUrlText) => {
const changeContent = (callback: ContentFormatter) => changeEditorContent(view, callback)
if (!file || !supportedMimeTypes.includes(file.type) || !changeContent) {
return
}
const randomId = Math.random().toString(36).slice(7)
const uploadFileInfo = description
? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description })
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})`
const noteId = getGlobalState().noteDetails.id
changeContent(({ currentSelection }) => {
return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false)
})
.catch((error: Error) => {
showErrorNotification('editor.upload.failed', { fileName: file.name })(error)
const replacement = `![upload of ${file.name} failed]()`
changeContent(({ markdownContent }) => [
replaceInContent(markdownContent, uploadPlaceholder, replacement),
undefined
])
})
}, [])
uploadFile(noteId, file)
.then(({ url }) => {
const replacement = `![${description ?? file.name ?? ''}](${url}${additionalUrlText ?? ''})`
changeContent(({ markdownContent }) => [
replaceInContent(markdownContent, uploadPlaceholder, replacement),
undefined
])
})
.catch((error: Error) => {
showErrorNotification('editor.upload.failed', { fileName: file.name })(error)
const replacement = `![upload of ${file.name} failed]()`
changeContent(({ markdownContent }) => [
replaceInContent(markdownContent, uploadPlaceholder, replacement),
undefined
])
})
},
[showErrorNotification]
)
}

View file

@ -11,12 +11,12 @@ import { SidebarButton } from '../sidebar-button/sidebar-button'
import type { SpecificSidebarEntryProps } from '../types'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { cypressId } from '../../../../utils/cypress-attribute'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { deleteNote } from '../../../../api/notes'
import { DeleteNoteModal } from './delete-note-modal'
import { useBooleanState } from '../../../../hooks/common/use-boolean-state'
import { useRouter } from 'next/router'
import { Logger } from '../../../../utils/logger'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
const logger = new Logger('note-deletion')
@ -31,6 +31,8 @@ export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarE
const router = useRouter()
const noteId = useApplicationState((state) => state.noteDetails.id)
const [modalVisibility, showModal, closeModal] = useBooleanState()
const { showErrorNotification } = useUiNotifications()
const deleteNoteAndCloseDialog = useCallback(() => {
deleteNote(noteId)
.then(() => {
@ -38,7 +40,7 @@ export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarE
})
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
.finally(closeModal)
}, [closeModal, noteId, router])
}, [closeModal, noteId, router, showErrorNotification])
return (
<Fragment>

View file

@ -9,9 +9,9 @@ import { Trans, useTranslation } from 'react-i18next'
import { SidebarButton } from '../sidebar-button/sidebar-button'
import type { SpecificSidebarEntryProps } from '../types'
import { toggleHistoryEntryPinning } from '../../../../redux/history/methods'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import styles from './pin-note-sidebar-entry.module.css'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
/**
* Sidebar entry button that toggles the pinned status of the current note in the history.
@ -23,6 +23,7 @@ export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ class
useTranslation()
const id = useApplicationState((state) => state.noteDetails.id)
const history = useApplicationState((state) => state.history)
const { showErrorNotification } = useUiNotifications()
const isPinned = useMemo(() => {
const entry = history.find((entry) => entry.identifier === id)
@ -34,7 +35,7 @@ export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ class
const onPinClicked = useCallback(() => {
toggleHistoryEntryPinning(id).catch(showErrorNotification('landing.history.error.updateEntry.text'))
}, [id])
}, [id, showErrorNotification])
return (
<SidebarButton

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -13,11 +13,11 @@ import { HistoryTable } from '../history-table/history-table'
import { ViewStateEnum } from '../history-toolbar/history-toolbar'
import { removeHistoryEntry, toggleHistoryEntryPinning } from '../../../redux/history/methods'
import { deleteNote } from '../../../api/notes'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { sortAndFilterEntries } from '../utils'
import { useHistoryToolbarState } from '../history-toolbar/toolbar-context/use-history-toolbar-state'
import type { HistoryEntryWithOrigin } from '../../../api/history/types'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
type OnEntryClick = (entryId: string) => void
@ -46,27 +46,36 @@ export const HistoryContent: React.FC = () => {
const [lastPageIndex, setLastPageIndex] = useState(0)
const allEntries = useApplicationState((state) => state.history)
const [historyToolbarState] = useHistoryToolbarState()
const { showErrorNotification } = useUiNotifications()
const entriesToShow = useMemo<HistoryEntryWithOrigin[]>(
() => sortAndFilterEntries(allEntries, historyToolbarState),
[allEntries, historyToolbarState]
)
const onPinClick = useCallback((noteId: string) => {
toggleHistoryEntryPinning(noteId).catch(showErrorNotification('landing.history.error.updateEntry.text'))
}, [])
const onPinClick = useCallback(
(noteId: string) => {
toggleHistoryEntryPinning(noteId).catch(showErrorNotification('landing.history.error.updateEntry.text'))
},
[showErrorNotification]
)
const onDeleteClick = useCallback((noteId: string) => {
deleteNote(noteId)
.then(() => removeHistoryEntry(noteId))
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
}, [])
const onDeleteClick = useCallback(
(noteId: string) => {
deleteNote(noteId)
.then(() => removeHistoryEntry(noteId))
.catch(showErrorNotification('landing.history.error.deleteNote.text'))
},
[showErrorNotification]
)
const onRemoveClick = useCallback((noteId: string) => {
removeHistoryEntry(noteId).catch(showErrorNotification('landing.history.error.deleteEntry.text'))
}, [])
const onRemoveClick = useCallback(
(noteId: string) => {
removeHistoryEntry(noteId).catch(showErrorNotification('landing.history.error.deleteEntry.text'))
},
[showErrorNotification]
)
const historyContent = useMemo(() => {
switch (historyToolbarState.viewState) {

View file

@ -9,10 +9,11 @@ import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { DeletionModal } from '../../common/modals/deletion-modal'
import { deleteAllHistoryEntries, safeRefreshHistoryState } from '../../../redux/history/methods'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { deleteAllHistoryEntries } from '../../../redux/history/methods'
import { cypressId } from '../../../utils/cypress-attribute'
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
/**
* Renders a button to clear the complete history of the user.
@ -21,6 +22,8 @@ import { useBooleanState } from '../../../hooks/common/use-boolean-state'
export const ClearHistoryButton: React.FC = () => {
const { t } = useTranslation()
const [modalVisibility, showModal, closeModal] = useBooleanState()
const { showErrorNotification } = useUiNotifications()
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
const onConfirm = useCallback(() => {
deleteAllHistoryEntries().catch((error: Error) => {
@ -28,7 +31,7 @@ export const ClearHistoryButton: React.FC = () => {
safeRefreshHistoryState()
})
closeModal()
}, [closeModal])
}, [closeModal, safeRefreshHistoryState, showErrorNotification])
return (
<Fragment>

View file

@ -1,14 +1,14 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import React from 'react'
import { Button } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { safeRefreshHistoryState } from '../../../redux/history/methods'
import { useTranslation } from 'react-i18next'
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
/**
* Fetches the current history from the server.
@ -16,9 +16,7 @@ import { useTranslation } from 'react-i18next'
export const HistoryRefreshButton: React.FC = () => {
const { t } = useTranslation()
const refreshHistory = useCallback(() => {
safeRefreshHistoryState()
}, [])
const refreshHistory = useSafeRefreshHistoryStateCallback()
return (
<Button variant={'light'} title={t('landing.history.toolbar.refresh')} onClick={refreshHistory}>

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -12,8 +12,7 @@ import { ShowIf } from '../../common/show-if/show-if'
import { ClearHistoryButton } from './clear-history-button'
import { ExportHistoryButton } from './export-history-button'
import { ImportHistoryButton } from './import-history-button'
import { importHistoryEntries, safeRefreshHistoryState, setHistoryEntries } from '../../../redux/history/methods'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { importHistoryEntries, setHistoryEntries } from '../../../redux/history/methods'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { KeywordSearchInput } from './keyword-search-input'
import { TagSelectionInput } from './tag-selection-input'
@ -23,6 +22,8 @@ import { SortByLastVisitedButton } from './sort-by-last-visited-button'
import { HistoryViewModeToggleButton } from './history-view-mode-toggle-button'
import { useSyncToolbarStateToUrlEffect } from './toolbar-context/use-sync-toolbar-state-to-url-effect'
import { HistoryEntryOrigin } from '../../../api/history/types'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
export enum ViewStateEnum {
CARD,
@ -36,7 +37,8 @@ export const HistoryToolbar: React.FC = () => {
const { t } = useTranslation()
const historyEntries = useApplicationState((state) => state.history)
const userExists = useApplicationState((state) => !!state.user)
const { showErrorNotification } = useUiNotifications()
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
useSyncToolbarStateToUrlEffect()
const onUploadAllToRemote = useCallback(() => {
@ -57,7 +59,7 @@ export const HistoryToolbar: React.FC = () => {
setHistoryEntries(historyEntries)
safeRefreshHistoryState()
})
}, [userExists, historyEntries])
}, [userExists, historyEntries, showErrorNotification, safeRefreshHistoryState])
return (
<Form inline={true}>

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
import { useCallback } from 'react'
import { refreshHistoryState } from '../../../../redux/history/methods'
/**
* Tries to refresh the history from the backend and shows notification if that request fails.
*/
export const useSafeRefreshHistoryStateCallback = () => {
const { showErrorNotification } = useUiNotifications()
return useCallback(() => {
refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text'))
}, [showErrorNotification])
}

View file

@ -9,17 +9,13 @@ import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import type { HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
import {
convertV1History,
importHistoryEntries,
mergeHistoryEntries,
safeRefreshHistoryState
} from '../../../redux/history/methods'
import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods'
import { convertV1History, importHistoryEntries, mergeHistoryEntries } from '../../../redux/history/methods'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { cypressId } from '../../../utils/cypress-attribute'
import type { HistoryEntryWithOrigin } from '../../../api/history/types'
import { HistoryEntryOrigin } from '../../../api/history/types'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
/**
* Button that lets the user select a history JSON file and uploads imports that into the history.
@ -30,6 +26,8 @@ export const ImportHistoryButton: React.FC = () => {
const historyState = useApplicationState((state) => state.history)
const uploadInput = useRef<HTMLInputElement>(null)
const [fileName, setFilename] = useState('')
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
const onImportHistory = useCallback(
(entries: HistoryEntryWithOrigin[]): void => {
@ -39,7 +37,7 @@ export const ImportHistoryButton: React.FC = () => {
safeRefreshHistoryState()
})
},
[historyState, userExists]
[historyState, safeRefreshHistoryState, showErrorNotification, userExists]
)
const resetInputField = useCallback(() => {

View file

@ -10,7 +10,6 @@ import { Container } from 'react-bootstrap'
import { MotdModal } from '../common/motd-modal/motd-modal'
import { Footer } from './footer/footer'
import { HeaderBar } from './navigation/header-bar/header-bar'
import { UiNotifications } from '../notifications/ui-notifications'
/**
* Renders the layout for both intro and history page.
@ -20,7 +19,6 @@ import { UiNotifications } from '../notifications/ui-notifications'
export const LandingLayout: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
return (
<Fragment>
<UiNotifications />
<MotdModal />
<Container className='text-light d-flex flex-column mvh-100'>
<HeaderBar />

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -11,18 +11,19 @@ import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { Trans, useTranslation } from 'react-i18next'
import { Dropdown } from 'react-bootstrap'
import { doLogout } from '../../../api/auth'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
/**
* Renders a sign-out button as a dropdown item for the user-dropdown.
*/
export const SignOutDropdownButton: React.FC = () => {
useTranslation()
const { showErrorNotification } = useUiNotifications()
const onSignOut = useCallback(() => {
clearUser()
doLogout().catch(showErrorNotification('login.logoutFailed'))
}, [])
}, [showErrorNotification])
return (
<Dropdown.Item dir='auto' onClick={onSignOut} {...cypressId('user-dropdown-sign-out-button')}>

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { TOptions } from 'i18next'
import type { IconName } from '../common/fork-awesome/types'
export interface UiNotificationButton {
label: string
onClick: () => void
}
export interface DispatchOptions {
titleI18nOptions: TOptions | string
contentI18nOptions: TOptions | string
durationInSecond: number
icon?: IconName
buttons: UiNotificationButton[]
}
export interface UiNotification extends DispatchOptions {
titleI18nKey: string
contentI18nKey: string
createdAtTimestamp: number
dismissed: boolean
uuid: string
}

View file

@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { UiNotifications } from './ui-notifications'
import type { DispatchOptions, UiNotification } from './types'
import { DateTime } from 'luxon'
import type { TOptions } from 'i18next'
import { t } from 'i18next'
import { Logger } from '../../utils/logger'
import { v4 as uuid } from 'uuid'
const log = new Logger('Notifications')
interface UiNotificationContext {
dispatchUiNotification: (
titleI18nKey: string,
contentI18nKey: string,
dispatchOptions: Partial<DispatchOptions>
) => void
showErrorNotification: (messageI18nKey: string, messageI18nOptions?: TOptions | string) => (error: Error) => void
dismissNotification: (notificationUuid: string) => void
}
/**
* Provides utility functions to manipulate the notifications in the current context.
*/
export const useUiNotifications: () => UiNotificationContext = () => {
const communicatorFromContext = useContext(uiNotificationContext)
if (!communicatorFromContext) {
throw new Error('No ui notifications')
}
return communicatorFromContext
}
export const DEFAULT_DURATION_IN_SECONDS = 10
const uiNotificationContext = createContext<UiNotificationContext | undefined>(undefined)
/**
* Provides a UI-notification context for the given children.
*
* @param children The children that receive the context
*/
export const UiNotificationBoundary: React.FC<PropsWithChildren> = ({ children }) => {
const [uiNotifications, setUiNotifications] = useState<UiNotification[]>([])
const dispatchUiNotification = useCallback(
(
titleI18nKey: string,
contentI18nKey: string,
{ icon, durationInSecond, buttons, contentI18nOptions, titleI18nOptions }: Partial<DispatchOptions>
) => {
setUiNotifications((oldState) => [
...oldState,
{
titleI18nKey,
contentI18nKey,
createdAtTimestamp: DateTime.now().toSeconds(),
dismissed: false,
titleI18nOptions: titleI18nOptions ?? {},
contentI18nOptions: contentI18nOptions ?? {},
durationInSecond: durationInSecond ?? DEFAULT_DURATION_IN_SECONDS,
buttons: buttons ?? [],
icon: icon,
uuid: uuid()
}
])
},
[]
)
const showErrorNotification = useCallback(
(messageI18nKey: string, messageI18nOptions?: TOptions | string) =>
(error: Error): void => {
log.error(t(messageI18nKey, messageI18nOptions), error)
void dispatchUiNotification('common.errorOccurred', messageI18nKey, {
contentI18nOptions: messageI18nOptions,
icon: 'exclamation-triangle'
})
},
[dispatchUiNotification]
)
const dismissNotification = useCallback((notificationUuid: string): void => {
setUiNotifications((old) => {
const found = old.find((notification) => notification.uuid === notificationUuid)
if (found === undefined) {
return old
}
return old.filter((value) => value.uuid !== notificationUuid).concat({ ...found, dismissed: true })
})
}, [])
const context = useMemo(() => {
return {
dispatchUiNotification: dispatchUiNotification,
showErrorNotification: showErrorNotification,
dismissNotification: dismissNotification
}
}, [dismissNotification, dispatchUiNotification, showErrorNotification])
return (
<uiNotificationContext.Provider value={context}>
<UiNotifications notifications={uiNotifications} />
{children}
</uiNotificationContext.Provider>
)
}

View file

@ -6,7 +6,6 @@
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Button, ProgressBar, Toast } from 'react-bootstrap'
import type { UiNotification } from '../../redux/ui-notifications/types'
import { ForkAwesomeIcon } from '../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../common/show-if/show-if'
import type { IconName } from '../common/fork-awesome/types'
@ -14,58 +13,40 @@ import { Trans, useTranslation } from 'react-i18next'
import { Logger } from '../../utils/logger'
import { cypressId } from '../../utils/cypress-attribute'
import { useEffectOnce, useInterval } from 'react-use'
import { dismissUiNotification } from '../../redux/ui-notifications/methods'
import styles from './notifications.module.scss'
import { DateTime } from 'luxon'
import type { UiNotification } from './types'
import { useUiNotifications } from './ui-notification-boundary'
const STEPS_PER_SECOND = 10
const log = new Logger('UiNotificationToast')
export interface UiNotificationProps extends UiNotification {
notificationId: number
export interface UiNotificationProps {
notification: UiNotification
}
/**
* Renders a single notification.
*
* @param titleI18nKey The i18n key for the title
* @param contentI18nKey The i18n key for the content
* @param titleI18nOptions The i18n options for the title
* @param contentI18nOptions The i18n options for the content
* @param createdAtTimestamp The timestamp, when this notification was created.
* @param icon The optional icon to be used
* @param dismissed If the notification is already dismissed
* @param notificationId The notification id
* @param durationInSecond How long the notification should be shown
* @param buttons A list of {@link UiNotificationButton UiNotificationButtons} to be displayed
* @param notification The notification to render
*/
export const UiNotificationToast: React.FC<UiNotificationProps> = ({
titleI18nKey,
contentI18nKey,
titleI18nOptions,
contentI18nOptions,
createdAtTimestamp,
icon,
dismissed,
notificationId,
durationInSecond,
buttons
}) => {
export const UiNotificationToast: React.FC<UiNotificationProps> = ({ notification }) => {
const { t } = useTranslation()
const [remainingSteps, setRemainingSteps] = useState<number>(() => durationInSecond * STEPS_PER_SECOND)
const [remainingSteps, setRemainingSteps] = useState<number>(() => notification.durationInSecond * STEPS_PER_SECOND)
const { dismissNotification } = useUiNotifications()
const dismissNow = useCallback(() => {
log.debug(`Dismiss notification ${notificationId} immediately`)
log.debug(`Dismiss notification ${notification.uuid} immediately`)
setRemainingSteps(0)
}, [notificationId])
}, [notification.uuid])
useEffectOnce(() => {
log.debug(`Show notification ${notificationId}`)
log.debug(`Show notification ${notification.uuid}`)
})
const formatCreatedAtDate = useCallback(() => {
return DateTime.fromSeconds(createdAtTimestamp).toRelative({ style: 'short' })
}, [createdAtTimestamp])
return DateTime.fromSeconds(notification.createdAtTimestamp).toRelative({ style: 'short' })
}, [notification])
const [formattedCreatedAtDate, setFormattedCreatedAtDate] = useState(() => formatCreatedAtDate())
@ -74,19 +55,19 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
setRemainingSteps((lastRemainingSteps) => lastRemainingSteps - 1)
setFormattedCreatedAtDate(formatCreatedAtDate())
},
!dismissed && remainingSteps > 0 ? 1000 / STEPS_PER_SECOND : null
!notification.dismissed && remainingSteps > 0 ? 1000 / STEPS_PER_SECOND : null
)
useEffect(() => {
if (remainingSteps <= 0 && !dismissed) {
log.debug(`Dismiss notification ${notificationId}`)
dismissUiNotification(notificationId)
if (remainingSteps <= 0 && !notification.dismissed) {
log.debug(`Dismiss notification ${notification.uuid}`)
dismissNotification(notification.uuid)
}
}, [dismissed, remainingSteps, notificationId])
}, [remainingSteps, notification.dismissed, notification.uuid, dismissNotification])
const buttonsDom = useMemo(
() =>
buttons?.map((button, buttonIndex) => {
notification.buttons?.map((button, buttonIndex) => {
const buttonClick = () => {
button.onClick()
dismissNow()
@ -97,11 +78,11 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
</Button>
)
}),
[buttons, dismissNow]
[dismissNow, notification.buttons]
)
const contentDom = useMemo(() => {
return t(contentI18nKey, contentI18nOptions)
return t(notification.contentI18nKey, notification.contentI18nOptions)
.split('\n')
.map((value, lineNumber) => {
return (
@ -111,16 +92,20 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
</Fragment>
)
})
}, [contentI18nKey, contentI18nOptions, t])
}, [notification.contentI18nKey, notification.contentI18nOptions, t])
return (
<Toast className={styles.toast} show={!dismissed} onClose={dismissNow} {...cypressId('notification-toast')}>
<Toast
className={styles.toast}
show={!notification.dismissed}
onClose={dismissNow}
{...cypressId('notification-toast')}>
<Toast.Header>
<strong className='mr-auto'>
<ShowIf condition={!!icon}>
<ForkAwesomeIcon icon={icon as IconName} fixedWidth={true} className={'mr-1'} />
<ShowIf condition={!!notification.icon}>
<ForkAwesomeIcon icon={notification.icon as IconName} fixedWidth={true} className={'mr-1'} />
</ShowIf>
<Trans i18nKey={titleI18nKey} tOptions={titleI18nOptions} />
<Trans i18nKey={notification.titleI18nKey} tOptions={notification.titleI18nOptions} />
</strong>
<small>{formattedCreatedAtDate}</small>
</Toast.Header>
@ -128,7 +113,7 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
<ProgressBar
variant={'info'}
now={remainingSteps}
max={durationInSecond * STEPS_PER_SECOND}
max={notification.durationInSecond * STEPS_PER_SECOND}
min={STEPS_PER_SECOND}
className={styles.progress}
/>

View file

@ -7,18 +7,22 @@
import React, { useMemo } from 'react'
import { UiNotificationToast } from './ui-notification-toast'
import styles from './notifications.module.scss'
import { useApplicationState } from '../../hooks/common/use-application-state'
import type { UiNotification } from './types'
export interface UiNotificationsProps {
notifications: UiNotification[]
}
/**
* Renders {@link UiNotification notifications} in the top right corner.
* Renders {@link UiNotification notifications} in the top right corner sorted by creation time..
*
* @param notifications The notification to render
*/
export const UiNotifications: React.FC = () => {
const notifications = useApplicationState((state) => state.uiNotifications)
export const UiNotifications: React.FC<UiNotificationsProps> = ({ notifications }) => {
const notificationElements = useMemo(() => {
return notifications.map((notification, notificationIndex) => (
<UiNotificationToast key={notificationIndex} notificationId={notificationIndex} {...notification} />
))
return notifications
.sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp)
.map((notification) => <UiNotificationToast key={notification.uuid} notification={notification} />)
}, [notifications])
return (

View file

@ -8,8 +8,8 @@ import { DateTime } from 'luxon'
import type { FormEvent } from 'react'
import { useCallback } from 'react'
import { postNewAccessToken } from '../../../../../api/tokens'
import { showErrorNotification } from '../../../../../redux/ui-notifications/methods'
import type { AccessTokenWithSecret } from '../../../../../api/tokens/types'
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
/**
* Callback for requesting a new access token from the API and returning the response token and secret.
@ -24,6 +24,8 @@ export const useOnCreateToken = (
expiryDate: string,
setNewTokenWithSecret: (token: AccessTokenWithSecret) => void
): ((event: FormEvent) => void) => {
const { showErrorNotification } = useUiNotifications()
return useCallback(
(event: FormEvent) => {
event.preventDefault()
@ -34,6 +36,6 @@ export const useOnCreateToken = (
})
.catch(showErrorNotification('profile.accessTokens.creationFailed'))
},
[expiryDate, label, setNewTokenWithSecret]
[expiryDate, label, setNewTokenWithSecret, showErrorNotification]
)
}

View file

@ -12,7 +12,7 @@ import { Button, Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import type { AccessToken } from '../../../api/tokens/types'
import { deleteAccessToken } from '../../../api/tokens'
import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
export interface AccessTokenDeletionModalProps extends ModalVisibilityProps {
token: AccessToken
@ -27,6 +27,7 @@ export interface AccessTokenDeletionModalProps extends ModalVisibilityProps {
*/
export const AccessTokenDeletionModal: React.FC<AccessTokenDeletionModalProps> = ({ show, token, onHide }) => {
useTranslation()
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
const onConfirmDelete = useCallback(() => {
deleteAccessToken(token.keyId)
@ -43,7 +44,7 @@ export const AccessTokenDeletionModal: React.FC<AccessTokenDeletionModalProps> =
})
.catch(showErrorNotification('profile.modal.deleteAccessToken.failed'))
.finally(() => onHide?.())
}, [token, onHide])
}, [token.keyId, token.label, showErrorNotification, dispatchUiNotification, onHide])
return (
<CommonModal

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -11,7 +11,7 @@ import type { AccessToken } from '../../../api/tokens/types'
import { ShowIf } from '../../common/show-if/show-if'
import { AccessTokenListEntry } from './access-token-list-entry'
import { AccessTokenCreationForm } from './access-token-creation-form/access-token-creation-form'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
export interface AccessTokenUpdateProps {
onUpdateList: () => void
@ -23,6 +23,7 @@ export interface AccessTokenUpdateProps {
export const ProfileAccessTokens: React.FC = () => {
useTranslation()
const [accessTokens, setAccessTokens] = useState<AccessToken[]>([])
const { showErrorNotification } = useUiNotifications()
const refreshAccessTokens = useCallback(() => {
getAccessTokenList()
@ -30,7 +31,7 @@ export const ProfileAccessTokens: React.FC = () => {
setAccessTokens(tokens)
})
.catch(showErrorNotification('profile.accessTokens.loadingFailed'))
}, [])
}, [showErrorNotification])
useEffect(() => {
refreshAccessTokens()

View file

@ -12,7 +12,7 @@ import { Button, Modal } from 'react-bootstrap'
import { CountdownButton } from '../../common/countdown-button/countdown-button'
import { deleteUser } from '../../../api/me'
import { clearUser } from '../../../redux/user/methods'
import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
/**
* Confirmation modal for deleting your account.
@ -22,6 +22,7 @@ import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui
*/
export const AccountDeletionModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation()
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
const deleteUserAccount = useCallback(() => {
deleteUser()
@ -39,7 +40,7 @@ export const AccountDeletionModal: React.FC<ModalVisibilityProps> = ({ show, onH
onHide()
}
})
}, [onHide])
}, [dispatchUiNotification, onHide, showErrorNotification])
return (
<CommonModal show={show} title={'profile.modal.deleteUser.message'} onHide={onHide} showCloseButton={true}>

View file

@ -9,17 +9,18 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { doLocalPasswordChange } from '../../../api/auth/local'
import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
import { NewPasswordField } from '../../common/fields/new-password-field'
import { PasswordAgainField } from '../../common/fields/password-again-field'
import { CurrentPasswordField } from '../../common/fields/current-password-field'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
/**
* Profile page section for changing the password when using internal login.
*/
export const ProfileChangePassword: React.FC = () => {
useTranslation()
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newPasswordAgain, setNewPasswordAgain] = useState('')
@ -34,11 +35,11 @@ export const ProfileChangePassword: React.FC = () => {
(event: FormEvent) => {
event.preventDefault()
doLocalPasswordChange(oldPassword, newPassword)
.then(() => {
return dispatchUiNotification('profile.changePassword.successTitle', 'profile.changePassword.successText', {
.then(() =>
dispatchUiNotification('profile.changePassword.successTitle', 'profile.changePassword.successText', {
icon: 'check'
})
})
)
.catch(showErrorNotification('profile.changePassword.failed'))
.finally(() => {
if (formRef.current) {
@ -49,7 +50,7 @@ export const ProfileChangePassword: React.FC = () => {
setNewPasswordAgain('')
})
},
[oldPassword, newPassword]
[oldPassword, newPassword, showErrorNotification, dispatchUiNotification]
)
const ready = useMemo(() => {

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -11,9 +11,9 @@ import { Trans, useTranslation } from 'react-i18next'
import { updateDisplayName } from '../../../api/me'
import { fetchAndSetUser } from '../../login-page/auth/utils'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { DisplayNameField } from '../../common/fields/display-name-field'
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
/**
* Profile page section for changing the current display name.
@ -22,6 +22,7 @@ export const ProfileDisplayName: React.FC = () => {
useTranslation()
const userName = useApplicationState((state) => state.user?.displayName)
const [displayName, setDisplayName] = useState(userName ?? '')
const { showErrorNotification } = useUiNotifications()
const onChangeDisplayName = useOnInputChange(setDisplayName)
const onSubmitNameChange = useCallback(
@ -31,7 +32,7 @@ export const ProfileDisplayName: React.FC = () => {
.then(fetchAndSetUser)
.catch(showErrorNotification('profile.changeDisplayNameFailed'))
},
[displayName]
[displayName, showErrorNotification]
)
const formSubmittable = useMemo(() => {

View file

@ -1,7 +1,7 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { AppProps } from 'next/app'
import { ErrorBoundary } from '../components/error-boundary/error-boundary'
@ -11,6 +11,7 @@ import '../../global-styles/index.scss'
import type { NextPage } from 'next'
import { BaseHead } from '../components/layout/base-head'
import { StoreProvider } from '../redux/store-provider'
import { UiNotificationBoundary } from '../components/notifications/ui-notification-boundary'
/**
* The actual hedgedoc next js app.
@ -22,7 +23,9 @@ const HedgeDocApp: NextPage<AppProps> = ({ Component, pageProps }: AppProps) =>
<BaseHead />
<ApplicationLoader>
<ErrorBoundary>
<Component {...pageProps} />
<UiNotificationBoundary>
<Component {...pageProps} />
</UiNotificationBoundary>
</ErrorBoundary>
</ApplicationLoader>
</StoreProvider>

View file

@ -1,17 +1,17 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useEffect } from 'react'
import type { NextPage } from 'next'
import { Trans, useTranslation } from 'react-i18next'
import { HistoryToolbar } from '../components/history-page/history-toolbar/history-toolbar'
import { safeRefreshHistoryState } from '../redux/history/methods'
import { Row } from 'react-bootstrap'
import { HistoryContent } from '../components/history-page/history-content/history-content'
import { LandingLayout } from '../components/landing-layout/landing-layout'
import { HistoryToolbarStateContextProvider } from '../components/history-page/history-toolbar/toolbar-context/history-toolbar-state-context-provider'
import { useSafeRefreshHistoryStateCallback } from '../components/history-page/history-toolbar/hooks/use-safe-refresh-history-state'
/**
* The page that shows the local and remote note history.
@ -19,9 +19,10 @@ import { HistoryToolbarStateContextProvider } from '../components/history-page/h
const HistoryPage: NextPage = () => {
useTranslation()
const safeRefreshHistoryStateCallback = useSafeRefreshHistoryStateCallback()
useEffect(() => {
safeRefreshHistoryState()
}, [])
safeRefreshHistoryStateCallback()
}, [safeRefreshHistoryStateCallback])
return (
<LandingLayout>

View file

@ -23,7 +23,7 @@ import { LandingLayout } from '../components/landing-layout/landing-layout'
import { useRouter } from 'next/router'
import type { NextPage } from 'next'
import { Redirect } from '../components/common/redirect'
import { dispatchUiNotification } from '../redux/ui-notifications/methods'
import { useUiNotifications } from '../components/notifications/ui-notification-boundary'
/**
* Renders the registration page with fields for username, display name, password, password retype and information about terms and conditions.
@ -40,6 +40,8 @@ export const RegisterPage: NextPage = () => {
const [passwordAgain, setPasswordAgain] = useState('')
const [error, setError] = useState<RegisterErrorType>()
const { dispatchUiNotification } = useUiNotifications()
const doRegisterSubmit = useCallback(
(event: FormEvent) => {
doLocalRegister(username, displayName, password)
@ -55,7 +57,7 @@ export const RegisterPage: NextPage = () => {
})
event.preventDefault()
},
[username, displayName, password, router]
[username, displayName, password, dispatchUiNotification, router]
)
const ready = useMemo(() => {

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -9,7 +9,6 @@ import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode'
import { MotdModal } from '../../components/common/motd-modal/motd-modal'
import { AppBar, AppBarMode } from '../../components/editor-page/app-bar/app-bar'
import { EditorToRendererCommunicatorContextProvider } from '../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { UiNotifications } from '../../components/notifications/ui-notifications'
import { DocumentReadOnlyPageContent } from '../../components/document-read-only-page/document-read-only-page-content'
import { NoteAndAppTitleHead } from '../../components/layout/note-and-app-title-head'
import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary'
@ -23,7 +22,6 @@ export const DocumentReadOnlyPage: React.FC = () => {
<EditorToRendererCommunicatorContextProvider>
<NoteLoadingBoundary>
<NoteAndAppTitleHead />
<UiNotifications />
<MotdModal />
<div className={'d-flex flex-column mvh-100 bg-light'}>
<AppBar mode={AppBarMode.BASIC} />

View file

@ -10,7 +10,6 @@ import type { OptionalMotdState } from './motd/types'
import type { EditorConfig } from './editor/types'
import type { DarkModeConfig } from './dark-mode/types'
import type { NoteDetails } from './note-details/types/note-details'
import type { UiNotificationState } from './ui-notifications/types'
import type { RendererStatus } from './renderer-status/types'
import type { HistoryEntryWithOrigin } from '../api/history/types'
import type { RealtimeState } from './realtime/types'
@ -23,7 +22,6 @@ export interface ApplicationState {
editorConfig: EditorConfig
darkMode: DarkModeConfig
noteDetails: NoteDetails
uiNotifications: UiNotificationState
rendererStatus: RendererStatus
realtime: RealtimeState
}

View file

@ -20,7 +20,6 @@ import { addRemoteOriginToHistoryEntry, historyEntryToHistoryEntryPutDto } from
import { Logger } from '../../utils/logger'
import type { HistoryEntry, HistoryEntryWithOrigin } from '../../api/history/types'
import { HistoryEntryOrigin } from '../../api/history/types'
import { showErrorNotification } from '../ui-notifications/methods'
const log = new Logger('Redux > History')
@ -177,13 +176,6 @@ export const refreshHistoryState = async (): Promise<void> => {
setHistoryEntries(allEntries)
}
/**
* Refreshes the history state and shows an error in case of failure.
*/
export const safeRefreshHistoryState = (): void => {
refreshHistoryState().catch(showErrorNotification('landing.history.error.getHistory.text'))
}
/**
* Stores the history entries marked as local from the redux to the user's local-storage.
*/

View file

@ -13,7 +13,6 @@ import { HistoryReducer } from './history/reducers'
import { EditorConfigReducer } from './editor/reducers'
import { DarkModeConfigReducer } from './dark-mode/reducers'
import { NoteDetailsReducer } from './note-details/reducer'
import { UiNotificationReducer } from './ui-notifications/reducers'
import { RendererStatusReducer } from './renderer-status/reducers'
import type { ApplicationState } from './application-state'
import { RealtimeReducer } from './realtime/reducers'
@ -26,7 +25,6 @@ export const allReducers: Reducer<ApplicationState> = combineReducers<Applicatio
editorConfig: EditorConfigReducer,
darkMode: DarkModeConfigReducer,
noteDetails: NoteDetailsReducer,
uiNotifications: UiNotificationReducer,
rendererStatus: RendererStatusReducer,
realtime: RealtimeReducer
})

View file

@ -1,83 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { TOptions } from 'i18next'
import { t } from 'i18next'
import { store } from '../index'
import type { DismissUiNotificationAction, DispatchOptions, DispatchUiNotificationAction } from './types'
import { UiNotificationActionType } from './types'
import { DateTime } from 'luxon'
import { Logger } from '../../utils/logger'
const log = new Logger('Redux > Notifications')
export const DEFAULT_DURATION_IN_SECONDS = 10
/**
* Dispatches a new UI Notification into the global application state.
*
* @param titleI18nKey I18n key used to show the localized title
* @param contentI18nKey I18n key used to show the localized content
* @param icon The icon in the upper left corner
* @param durationInSecond Show duration of the notification. If omitted then a {@link DEFAULT_DURATION_IN_SECONDS default value} will be used.
* @param buttons A array of actions that are shown in the notification
* @param contentI18nOptions Options to configure the translation of the title. (e.g. variables)
* @param titleI18nOptions Options to configure the translation of the content. (e.g. variables)
* @return a promise that resolves as soon as the notification id available.
*/
export const dispatchUiNotification = async (
titleI18nKey: string,
contentI18nKey: string,
{ icon, durationInSecond, buttons, contentI18nOptions, titleI18nOptions }: Partial<DispatchOptions>
): Promise<number> => {
return new Promise((resolve) => {
store.dispatch({
type: UiNotificationActionType.DISPATCH_NOTIFICATION,
notificationIdCallback: (notificationId: number) => {
resolve(notificationId)
},
notification: {
titleI18nKey,
contentI18nKey,
createdAtTimestamp: DateTime.now().toSeconds(),
dismissed: false,
titleI18nOptions: titleI18nOptions ?? {},
contentI18nOptions: contentI18nOptions ?? {},
durationInSecond: durationInSecond ?? DEFAULT_DURATION_IN_SECONDS,
buttons: buttons ?? [],
icon: icon
}
} as DispatchUiNotificationAction)
})
}
/**
* 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 => {
store.dispatch({
type: UiNotificationActionType.DISMISS_NOTIFICATION,
notificationId
} 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 =
(messageI18nKey: string, messageI18nOptions?: TOptions | string) =>
(error: Error): void => {
log.error(t(messageI18nKey, messageI18nOptions), error)
void dispatchUiNotification('common.errorOccurred', messageI18nKey, {
contentI18nOptions: messageI18nOptions,
icon: 'exclamation-triangle'
})
}

View file

@ -1,53 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Reducer } from 'redux'
import type { UiNotification, UiNotificationActions, UiNotificationState } from './types'
import { UiNotificationActionType } from './types'
export const UiNotificationReducer: Reducer<UiNotificationState, UiNotificationActions> = (
state: UiNotificationState = [],
action: UiNotificationActions
) => {
switch (action.type) {
case UiNotificationActionType.DISPATCH_NOTIFICATION:
return addNewNotification(state, action.notification, action.notificationIdCallback)
case UiNotificationActionType.DISMISS_NOTIFICATION:
return dismissNotification(state, action.notificationId)
default:
return state
}
}
/**
* 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 = (
notificationState: UiNotificationState,
notificationIndex: number
): UiNotificationState => {
const newArray = [...notificationState]
const oldNotification = newArray[notificationIndex]
newArray[notificationIndex] = {
...oldNotification,
dismissed: true
}
return newArray
}

View file

@ -1,49 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Action } from 'redux'
import type { IconName } from '../../components/common/fork-awesome/types'
import type { TOptions } from 'i18next'
export enum UiNotificationActionType {
DISPATCH_NOTIFICATION = 'notification/dispatch',
DISMISS_NOTIFICATION = 'notification/dismiss'
}
export interface UiNotificationButton {
label: string
onClick: () => void
}
export interface DispatchOptions {
titleI18nOptions: TOptions | string
contentI18nOptions: TOptions | string
durationInSecond: number
icon?: IconName
buttons: UiNotificationButton[]
}
export interface UiNotification extends DispatchOptions {
titleI18nKey: string
contentI18nKey: string
createdAtTimestamp: number
dismissed: boolean
}
export type UiNotificationActions = DispatchUiNotificationAction | DismissUiNotificationAction
export interface DispatchUiNotificationAction extends Action<UiNotificationActionType> {
type: UiNotificationActionType.DISPATCH_NOTIFICATION
notification: UiNotification
notificationIdCallback: (notificationId: number) => void
}
export interface DismissUiNotificationAction extends Action<UiNotificationActionType> {
type: UiNotificationActionType.DISMISS_NOTIFICATION
notificationId: number
}
export type UiNotificationState = UiNotification[]