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

View file

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

View file

@ -9,8 +9,8 @@ import { PermissionOwnerChange } from './permission-owner-change'
import { PermissionOwnerInfo } from './permission-owner-info' import { PermissionOwnerInfo } from './permission-owner-info'
import { setNoteOwner } from '../../../../api/permissions' import { setNoteOwner } from '../../../../api/permissions'
import { useApplicationState } from '../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/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. * 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 = () => { export const PermissionSectionOwner: React.FC = () => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress) const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const [changeOwner, setChangeOwner] = useState(false) const [changeOwner, setChangeOwner] = useState(false)
const { showErrorNotification } = useUiNotifications()
const onSetChangeOwner = useCallback(() => { const onSetChangeOwner = useCallback(() => {
setChangeOwner(true) setChangeOwner(true)
@ -34,7 +35,7 @@ export const PermissionSectionOwner: React.FC = () => {
setChangeOwner(false) setChangeOwner(false)
}) })
}, },
[noteId] [noteId, showErrorNotification]
) )
return ( return (

View file

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

View file

@ -13,10 +13,10 @@ import { UserAvatar } from '../../../common/user-avatar/user-avatar'
import styles from './revision-list-entry.module.scss' import styles from './revision-list-entry.module.scss'
import type { RevisionMetadata } from '../../../../api/revisions/types' import type { RevisionMetadata } from '../../../../api/revisions/types'
import { getUserDataForRevision } from './utils' import { getUserDataForRevision } from './utils'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useAsync } from 'react-use' import { useAsync } from 'react-use'
import { ShowIf } from '../../../common/show-if/show-if' import { ShowIf } from '../../../common/show-if/show-if'
import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner' import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface RevisionListEntryProps { export interface RevisionListEntryProps {
active: boolean active: boolean
@ -33,6 +33,7 @@ export interface RevisionListEntryProps {
*/ */
export const RevisionListEntry: React.FC<RevisionListEntryProps> = ({ active, onSelect, revision }) => { export const RevisionListEntry: React.FC<RevisionListEntryProps> = ({ active, onSelect, revision }) => {
useTranslation() useTranslation()
const { showErrorNotification } = useUiNotifications()
const revisionCreationTime = useMemo(() => { const revisionCreationTime = useMemo(() => {
return DateTime.fromISO(revision.createdAt).toFormat('DDDD T') 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 type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { useApplicationState } from '../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { getRevision } from '../../../../api/revisions' import { getRevision } from '../../../../api/revisions'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods' import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface RevisionModalFooterProps { export interface RevisionModalFooterProps {
selectedRevisionId?: number selectedRevisionId?: number
@ -29,6 +29,7 @@ export const RevisionModalFooter: React.FC<RevisionModalFooterProps & Pick<Modal
}) => { }) => {
useTranslation() useTranslation()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const { showErrorNotification } = useUiNotifications()
const onRevertToRevision = useCallback(() => { const onRevertToRevision = useCallback(() => {
// TODO Websocket message handler missing // TODO Websocket message handler missing
@ -45,7 +46,7 @@ export const RevisionModalFooter: React.FC<RevisionModalFooterProps & Pick<Modal
downloadRevision(noteIdentifier, revision) downloadRevision(noteIdentifier, revision)
}) })
.catch(showErrorNotification('')) .catch(showErrorNotification(''))
}, [noteIdentifier, selectedRevisionId]) }, [noteIdentifier, selectedRevisionId, showErrorNotification])
return ( return (
<Modal.Footer> <Modal.Footer>

View file

@ -16,7 +16,6 @@ import { Sidebar } from './sidebar/sidebar'
import { Splitter } from './splitter/splitter' import { Splitter } from './splitter/splitter'
import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props' import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
import { useEditorModeFromUrl } from './hooks/use-editor-mode-from-url' 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 { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-entry'
import { useApplicationState } from '../../hooks/common/use-application-state' import { useApplicationState } from '../../hooks/common/use-application-state'
import { EditorDocumentRenderer } from './editor-document-renderer/editor-document-renderer' import { EditorDocumentRenderer } from './editor-document-renderer/editor-document-renderer'
@ -128,7 +127,6 @@ export const EditorPageContent: React.FC = () => {
return ( return (
<ChangeEditorContentContextProvider> <ChangeEditorContentContextProvider>
<NoteAndAppTitleHead /> <NoteAndAppTitleHead />
<UiNotifications />
<MotdModal /> <MotdModal />
<div className={'d-flex flex-column vh-100'}> <div className={'d-flex flex-column vh-100'}>
<AppBar mode={AppBarMode.EDITOR} /> <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 { CursorSelection } from '../tool-bar/formatters/types/cursor-selection'
import type { EditorView } from '@codemirror/view' import type { EditorView } from '@codemirror/view'
import type { ContentFormatter } from '../../change-content-context/change-content-context' 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 * @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. * Provides a callback that uploads a given file and inserts the correct Markdown code into the current editor.
*/ */
export const useHandleUpload = (): handleUploadSignature => { export const useHandleUpload = (): handleUploadSignature => {
return useCallback((view, file, cursorSelection, description, additionalUrlText) => { const { showErrorNotification } = useUiNotifications()
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 ?? ''})` return useCallback(
const noteId = getGlobalState().noteDetails.id (view, file, cursorSelection, description, additionalUrlText) => {
changeContent(({ currentSelection }) => { const changeContent = (callback: ContentFormatter) => changeEditorContent(view, callback)
return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false) if (!file || !supportedMimeTypes.includes(file.type) || !changeContent) {
}) return
uploadFile(noteId, file) }
.then(({ url }) => { const randomId = Math.random().toString(36).slice(7)
const replacement = `![${description ?? file.name ?? ''}](${url}${additionalUrlText ?? ''})` const uploadFileInfo = description
changeContent(({ markdownContent }) => [ ? t('editor.upload.uploadFile.withDescription', { fileName: file.name, description: description })
replaceInContent(markdownContent, uploadPlaceholder, replacement), : t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
undefined
]) const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})`
const noteId = getGlobalState().noteDetails.id
changeContent(({ currentSelection }) => {
return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false)
}) })
.catch((error: Error) => { uploadFile(noteId, file)
showErrorNotification('editor.upload.failed', { fileName: file.name })(error) .then(({ url }) => {
const replacement = `![upload of ${file.name} failed]()` const replacement = `![${description ?? file.name ?? ''}](${url}${additionalUrlText ?? ''})`
changeContent(({ markdownContent }) => [ changeContent(({ markdownContent }) => [
replaceInContent(markdownContent, uploadPlaceholder, replacement), replaceInContent(markdownContent, uploadPlaceholder, replacement),
undefined 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 type { SpecificSidebarEntryProps } from '../types'
import { useApplicationState } from '../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { deleteNote } from '../../../../api/notes' import { deleteNote } from '../../../../api/notes'
import { DeleteNoteModal } from './delete-note-modal' import { DeleteNoteModal } from './delete-note-modal'
import { useBooleanState } from '../../../../hooks/common/use-boolean-state' import { useBooleanState } from '../../../../hooks/common/use-boolean-state'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Logger } from '../../../../utils/logger' import { Logger } from '../../../../utils/logger'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
const logger = new Logger('note-deletion') const logger = new Logger('note-deletion')
@ -31,6 +31,8 @@ export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarE
const router = useRouter() const router = useRouter()
const noteId = useApplicationState((state) => state.noteDetails.id) const noteId = useApplicationState((state) => state.noteDetails.id)
const [modalVisibility, showModal, closeModal] = useBooleanState() const [modalVisibility, showModal, closeModal] = useBooleanState()
const { showErrorNotification } = useUiNotifications()
const deleteNoteAndCloseDialog = useCallback(() => { const deleteNoteAndCloseDialog = useCallback(() => {
deleteNote(noteId) deleteNote(noteId)
.then(() => { .then(() => {
@ -38,7 +40,7 @@ export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarE
}) })
.catch(showErrorNotification('landing.history.error.deleteNote.text')) .catch(showErrorNotification('landing.history.error.deleteNote.text'))
.finally(closeModal) .finally(closeModal)
}, [closeModal, noteId, router]) }, [closeModal, noteId, router, showErrorNotification])
return ( return (
<Fragment> <Fragment>

View file

@ -9,9 +9,9 @@ import { Trans, useTranslation } from 'react-i18next'
import { SidebarButton } from '../sidebar-button/sidebar-button' import { SidebarButton } from '../sidebar-button/sidebar-button'
import type { SpecificSidebarEntryProps } from '../types' import type { SpecificSidebarEntryProps } from '../types'
import { toggleHistoryEntryPinning } from '../../../../redux/history/methods' import { toggleHistoryEntryPinning } from '../../../../redux/history/methods'
import { showErrorNotification } from '../../../../redux/ui-notifications/methods'
import { useApplicationState } from '../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../hooks/common/use-application-state'
import styles from './pin-note-sidebar-entry.module.css' 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. * 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() useTranslation()
const id = useApplicationState((state) => state.noteDetails.id) const id = useApplicationState((state) => state.noteDetails.id)
const history = useApplicationState((state) => state.history) const history = useApplicationState((state) => state.history)
const { showErrorNotification } = useUiNotifications()
const isPinned = useMemo(() => { const isPinned = useMemo(() => {
const entry = history.find((entry) => entry.identifier === id) const entry = history.find((entry) => entry.identifier === id)
@ -34,7 +35,7 @@ export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ class
const onPinClicked = useCallback(() => { const onPinClicked = useCallback(() => {
toggleHistoryEntryPinning(id).catch(showErrorNotification('landing.history.error.updateEntry.text')) toggleHistoryEntryPinning(id).catch(showErrorNotification('landing.history.error.updateEntry.text'))
}, [id]) }, [id, showErrorNotification])
return ( return (
<SidebarButton <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 * 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 { ViewStateEnum } from '../history-toolbar/history-toolbar'
import { removeHistoryEntry, toggleHistoryEntryPinning } from '../../../redux/history/methods' import { removeHistoryEntry, toggleHistoryEntryPinning } from '../../../redux/history/methods'
import { deleteNote } from '../../../api/notes' import { deleteNote } from '../../../api/notes'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { sortAndFilterEntries } from '../utils' import { sortAndFilterEntries } from '../utils'
import { useHistoryToolbarState } from '../history-toolbar/toolbar-context/use-history-toolbar-state' import { useHistoryToolbarState } from '../history-toolbar/toolbar-context/use-history-toolbar-state'
import type { HistoryEntryWithOrigin } from '../../../api/history/types' import type { HistoryEntryWithOrigin } from '../../../api/history/types'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
type OnEntryClick = (entryId: string) => void type OnEntryClick = (entryId: string) => void
@ -46,27 +46,36 @@ export const HistoryContent: React.FC = () => {
const [lastPageIndex, setLastPageIndex] = useState(0) const [lastPageIndex, setLastPageIndex] = useState(0)
const allEntries = useApplicationState((state) => state.history) const allEntries = useApplicationState((state) => state.history)
const [historyToolbarState] = useHistoryToolbarState() const [historyToolbarState] = useHistoryToolbarState()
const { showErrorNotification } = useUiNotifications()
const entriesToShow = useMemo<HistoryEntryWithOrigin[]>( const entriesToShow = useMemo<HistoryEntryWithOrigin[]>(
() => sortAndFilterEntries(allEntries, historyToolbarState), () => sortAndFilterEntries(allEntries, historyToolbarState),
[allEntries, historyToolbarState] [allEntries, historyToolbarState]
) )
const onPinClick = useCallback((noteId: string) => { const onPinClick = useCallback(
toggleHistoryEntryPinning(noteId).catch(showErrorNotification('landing.history.error.updateEntry.text')) (noteId: string) => {
}, []) toggleHistoryEntryPinning(noteId).catch(showErrorNotification('landing.history.error.updateEntry.text'))
},
[showErrorNotification]
)
const onDeleteClick = useCallback((noteId: string) => { const onDeleteClick = useCallback(
deleteNote(noteId) (noteId: string) => {
.then(() => removeHistoryEntry(noteId)) deleteNote(noteId)
.catch(showErrorNotification('landing.history.error.deleteNote.text')) .then(() => removeHistoryEntry(noteId))
}, []) .catch(showErrorNotification('landing.history.error.deleteNote.text'))
},
[showErrorNotification]
)
const onRemoveClick = useCallback((noteId: string) => { const onRemoveClick = useCallback(
removeHistoryEntry(noteId).catch(showErrorNotification('landing.history.error.deleteEntry.text')) (noteId: string) => {
}, []) removeHistoryEntry(noteId).catch(showErrorNotification('landing.history.error.deleteEntry.text'))
},
[showErrorNotification]
)
const historyContent = useMemo(() => { const historyContent = useMemo(() => {
switch (historyToolbarState.viewState) { switch (historyToolbarState.viewState) {

View file

@ -9,10 +9,11 @@ import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { DeletionModal } from '../../common/modals/deletion-modal' import { DeletionModal } from '../../common/modals/deletion-modal'
import { deleteAllHistoryEntries, safeRefreshHistoryState } from '../../../redux/history/methods' import { deleteAllHistoryEntries } from '../../../redux/history/methods'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import { useBooleanState } from '../../../hooks/common/use-boolean-state' 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. * 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 = () => { export const ClearHistoryButton: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [modalVisibility, showModal, closeModal] = useBooleanState() const [modalVisibility, showModal, closeModal] = useBooleanState()
const { showErrorNotification } = useUiNotifications()
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
const onConfirm = useCallback(() => { const onConfirm = useCallback(() => {
deleteAllHistoryEntries().catch((error: Error) => { deleteAllHistoryEntries().catch((error: Error) => {
@ -28,7 +31,7 @@ export const ClearHistoryButton: React.FC = () => {
safeRefreshHistoryState() safeRefreshHistoryState()
}) })
closeModal() closeModal()
}, [closeModal]) }, [closeModal, safeRefreshHistoryState, showErrorNotification])
return ( return (
<Fragment> <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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import React, { useCallback } from 'react' import React from 'react'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { safeRefreshHistoryState } from '../../../redux/history/methods'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSafeRefreshHistoryStateCallback } from './hooks/use-safe-refresh-history-state'
/** /**
* Fetches the current history from the server. * Fetches the current history from the server.
@ -16,9 +16,7 @@ import { useTranslation } from 'react-i18next'
export const HistoryRefreshButton: React.FC = () => { export const HistoryRefreshButton: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const refreshHistory = useCallback(() => { const refreshHistory = useSafeRefreshHistoryStateCallback()
safeRefreshHistoryState()
}, [])
return ( return (
<Button variant={'light'} title={t('landing.history.toolbar.refresh')} onClick={refreshHistory}> <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 * 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 { ClearHistoryButton } from './clear-history-button'
import { ExportHistoryButton } from './export-history-button' import { ExportHistoryButton } from './export-history-button'
import { ImportHistoryButton } from './import-history-button' import { ImportHistoryButton } from './import-history-button'
import { importHistoryEntries, safeRefreshHistoryState, setHistoryEntries } from '../../../redux/history/methods' import { importHistoryEntries, setHistoryEntries } from '../../../redux/history/methods'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { KeywordSearchInput } from './keyword-search-input' import { KeywordSearchInput } from './keyword-search-input'
import { TagSelectionInput } from './tag-selection-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 { HistoryViewModeToggleButton } from './history-view-mode-toggle-button'
import { useSyncToolbarStateToUrlEffect } from './toolbar-context/use-sync-toolbar-state-to-url-effect' import { useSyncToolbarStateToUrlEffect } from './toolbar-context/use-sync-toolbar-state-to-url-effect'
import { HistoryEntryOrigin } 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'
export enum ViewStateEnum { export enum ViewStateEnum {
CARD, CARD,
@ -36,7 +37,8 @@ export const HistoryToolbar: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const historyEntries = useApplicationState((state) => state.history) const historyEntries = useApplicationState((state) => state.history)
const userExists = useApplicationState((state) => !!state.user) const userExists = useApplicationState((state) => !!state.user)
const { showErrorNotification } = useUiNotifications()
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
useSyncToolbarStateToUrlEffect() useSyncToolbarStateToUrlEffect()
const onUploadAllToRemote = useCallback(() => { const onUploadAllToRemote = useCallback(() => {
@ -57,7 +59,7 @@ export const HistoryToolbar: React.FC = () => {
setHistoryEntries(historyEntries) setHistoryEntries(historyEntries)
safeRefreshHistoryState() safeRefreshHistoryState()
}) })
}, [userExists, historyEntries]) }, [userExists, historyEntries, showErrorNotification, safeRefreshHistoryState])
return ( return (
<Form inline={true}> <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 { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import type { HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types' import type { HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
import { import { convertV1History, importHistoryEntries, mergeHistoryEntries } from '../../../redux/history/methods'
convertV1History,
importHistoryEntries,
mergeHistoryEntries,
safeRefreshHistoryState
} from '../../../redux/history/methods'
import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import type { HistoryEntryWithOrigin } from '../../../api/history/types' import type { HistoryEntryWithOrigin } from '../../../api/history/types'
import { HistoryEntryOrigin } 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. * 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 historyState = useApplicationState((state) => state.history)
const uploadInput = useRef<HTMLInputElement>(null) const uploadInput = useRef<HTMLInputElement>(null)
const [fileName, setFilename] = useState('') const [fileName, setFilename] = useState('')
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
const safeRefreshHistoryState = useSafeRefreshHistoryStateCallback()
const onImportHistory = useCallback( const onImportHistory = useCallback(
(entries: HistoryEntryWithOrigin[]): void => { (entries: HistoryEntryWithOrigin[]): void => {
@ -39,7 +37,7 @@ export const ImportHistoryButton: React.FC = () => {
safeRefreshHistoryState() safeRefreshHistoryState()
}) })
}, },
[historyState, userExists] [historyState, safeRefreshHistoryState, showErrorNotification, userExists]
) )
const resetInputField = useCallback(() => { const resetInputField = useCallback(() => {

View file

@ -10,7 +10,6 @@ import { Container } from 'react-bootstrap'
import { MotdModal } from '../common/motd-modal/motd-modal' import { MotdModal } from '../common/motd-modal/motd-modal'
import { Footer } from './footer/footer' import { Footer } from './footer/footer'
import { HeaderBar } from './navigation/header-bar/header-bar' import { HeaderBar } from './navigation/header-bar/header-bar'
import { UiNotifications } from '../notifications/ui-notifications'
/** /**
* Renders the layout for both intro and history page. * 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 }) => { export const LandingLayout: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
return ( return (
<Fragment> <Fragment>
<UiNotifications />
<MotdModal /> <MotdModal />
<Container className='text-light d-flex flex-column mvh-100'> <Container className='text-light d-flex flex-column mvh-100'>
<HeaderBar /> <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 * 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 { Trans, useTranslation } from 'react-i18next'
import { Dropdown } from 'react-bootstrap' import { Dropdown } from 'react-bootstrap'
import { doLogout } from '../../../api/auth' 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. * Renders a sign-out button as a dropdown item for the user-dropdown.
*/ */
export const SignOutDropdownButton: React.FC = () => { export const SignOutDropdownButton: React.FC = () => {
useTranslation() useTranslation()
const { showErrorNotification } = useUiNotifications()
const onSignOut = useCallback(() => { const onSignOut = useCallback(() => {
clearUser() clearUser()
doLogout().catch(showErrorNotification('login.logoutFailed')) doLogout().catch(showErrorNotification('login.logoutFailed'))
}, []) }, [showErrorNotification])
return ( return (
<Dropdown.Item dir='auto' onClick={onSignOut} {...cypressId('user-dropdown-sign-out-button')}> <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 React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Button, ProgressBar, Toast } from 'react-bootstrap' 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 { 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 type { IconName } from '../common/fork-awesome/types' import type { IconName } from '../common/fork-awesome/types'
@ -14,58 +13,40 @@ import { Trans, useTranslation } from 'react-i18next'
import { Logger } from '../../utils/logger' import { Logger } from '../../utils/logger'
import { cypressId } from '../../utils/cypress-attribute' import { cypressId } from '../../utils/cypress-attribute'
import { useEffectOnce, useInterval } from 'react-use' import { useEffectOnce, useInterval } from 'react-use'
import { dismissUiNotification } from '../../redux/ui-notifications/methods'
import styles from './notifications.module.scss' import styles from './notifications.module.scss'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import type { UiNotification } from './types'
import { useUiNotifications } from './ui-notification-boundary'
const STEPS_PER_SECOND = 10 const STEPS_PER_SECOND = 10
const log = new Logger('UiNotificationToast') const log = new Logger('UiNotificationToast')
export interface UiNotificationProps extends UiNotification { export interface UiNotificationProps {
notificationId: number notification: UiNotification
} }
/** /**
* Renders a single notification. * Renders a single notification.
* *
* @param titleI18nKey The i18n key for the title * @param notification The notification to render
* @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
*/ */
export const UiNotificationToast: React.FC<UiNotificationProps> = ({ export const UiNotificationToast: React.FC<UiNotificationProps> = ({ notification }) => {
titleI18nKey,
contentI18nKey,
titleI18nOptions,
contentI18nOptions,
createdAtTimestamp,
icon,
dismissed,
notificationId,
durationInSecond,
buttons
}) => {
const { t } = useTranslation() 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(() => { const dismissNow = useCallback(() => {
log.debug(`Dismiss notification ${notificationId} immediately`) log.debug(`Dismiss notification ${notification.uuid} immediately`)
setRemainingSteps(0) setRemainingSteps(0)
}, [notificationId]) }, [notification.uuid])
useEffectOnce(() => { useEffectOnce(() => {
log.debug(`Show notification ${notificationId}`) log.debug(`Show notification ${notification.uuid}`)
}) })
const formatCreatedAtDate = useCallback(() => { const formatCreatedAtDate = useCallback(() => {
return DateTime.fromSeconds(createdAtTimestamp).toRelative({ style: 'short' }) return DateTime.fromSeconds(notification.createdAtTimestamp).toRelative({ style: 'short' })
}, [createdAtTimestamp]) }, [notification])
const [formattedCreatedAtDate, setFormattedCreatedAtDate] = useState(() => formatCreatedAtDate()) const [formattedCreatedAtDate, setFormattedCreatedAtDate] = useState(() => formatCreatedAtDate())
@ -74,19 +55,19 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
setRemainingSteps((lastRemainingSteps) => lastRemainingSteps - 1) setRemainingSteps((lastRemainingSteps) => lastRemainingSteps - 1)
setFormattedCreatedAtDate(formatCreatedAtDate()) setFormattedCreatedAtDate(formatCreatedAtDate())
}, },
!dismissed && remainingSteps > 0 ? 1000 / STEPS_PER_SECOND : null !notification.dismissed && remainingSteps > 0 ? 1000 / STEPS_PER_SECOND : null
) )
useEffect(() => { useEffect(() => {
if (remainingSteps <= 0 && !dismissed) { if (remainingSteps <= 0 && !notification.dismissed) {
log.debug(`Dismiss notification ${notificationId}`) log.debug(`Dismiss notification ${notification.uuid}`)
dismissUiNotification(notificationId) dismissNotification(notification.uuid)
} }
}, [dismissed, remainingSteps, notificationId]) }, [remainingSteps, notification.dismissed, notification.uuid, dismissNotification])
const buttonsDom = useMemo( const buttonsDom = useMemo(
() => () =>
buttons?.map((button, buttonIndex) => { notification.buttons?.map((button, buttonIndex) => {
const buttonClick = () => { const buttonClick = () => {
button.onClick() button.onClick()
dismissNow() dismissNow()
@ -97,11 +78,11 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
</Button> </Button>
) )
}), }),
[buttons, dismissNow] [dismissNow, notification.buttons]
) )
const contentDom = useMemo(() => { const contentDom = useMemo(() => {
return t(contentI18nKey, contentI18nOptions) return t(notification.contentI18nKey, notification.contentI18nOptions)
.split('\n') .split('\n')
.map((value, lineNumber) => { .map((value, lineNumber) => {
return ( return (
@ -111,16 +92,20 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
</Fragment> </Fragment>
) )
}) })
}, [contentI18nKey, contentI18nOptions, t]) }, [notification.contentI18nKey, notification.contentI18nOptions, t])
return ( 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> <Toast.Header>
<strong className='mr-auto'> <strong className='mr-auto'>
<ShowIf condition={!!icon}> <ShowIf condition={!!notification.icon}>
<ForkAwesomeIcon icon={icon as IconName} fixedWidth={true} className={'mr-1'} /> <ForkAwesomeIcon icon={notification.icon as IconName} fixedWidth={true} className={'mr-1'} />
</ShowIf> </ShowIf>
<Trans i18nKey={titleI18nKey} tOptions={titleI18nOptions} /> <Trans i18nKey={notification.titleI18nKey} tOptions={notification.titleI18nOptions} />
</strong> </strong>
<small>{formattedCreatedAtDate}</small> <small>{formattedCreatedAtDate}</small>
</Toast.Header> </Toast.Header>
@ -128,7 +113,7 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
<ProgressBar <ProgressBar
variant={'info'} variant={'info'}
now={remainingSteps} now={remainingSteps}
max={durationInSecond * STEPS_PER_SECOND} max={notification.durationInSecond * STEPS_PER_SECOND}
min={STEPS_PER_SECOND} min={STEPS_PER_SECOND}
className={styles.progress} className={styles.progress}
/> />

View file

@ -7,18 +7,22 @@
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { UiNotificationToast } from './ui-notification-toast' import { UiNotificationToast } from './ui-notification-toast'
import styles from './notifications.module.scss' 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 = () => { export const UiNotifications: React.FC<UiNotificationsProps> = ({ notifications }) => {
const notifications = useApplicationState((state) => state.uiNotifications)
const notificationElements = useMemo(() => { const notificationElements = useMemo(() => {
return notifications.map((notification, notificationIndex) => ( return notifications
<UiNotificationToast key={notificationIndex} notificationId={notificationIndex} {...notification} /> .sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp)
)) .map((notification) => <UiNotificationToast key={notification.uuid} notification={notification} />)
}, [notifications]) }, [notifications])
return ( return (

View file

@ -8,8 +8,8 @@ import { DateTime } from 'luxon'
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { postNewAccessToken } from '../../../../../api/tokens' import { postNewAccessToken } from '../../../../../api/tokens'
import { showErrorNotification } from '../../../../../redux/ui-notifications/methods'
import type { AccessTokenWithSecret } from '../../../../../api/tokens/types' 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. * 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, expiryDate: string,
setNewTokenWithSecret: (token: AccessTokenWithSecret) => void setNewTokenWithSecret: (token: AccessTokenWithSecret) => void
): ((event: FormEvent) => void) => { ): ((event: FormEvent) => void) => {
const { showErrorNotification } = useUiNotifications()
return useCallback( return useCallback(
(event: FormEvent) => { (event: FormEvent) => {
event.preventDefault() event.preventDefault()
@ -34,6 +36,6 @@ export const useOnCreateToken = (
}) })
.catch(showErrorNotification('profile.accessTokens.creationFailed')) .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 { Trans, useTranslation } from 'react-i18next'
import type { AccessToken } from '../../../api/tokens/types' import type { AccessToken } from '../../../api/tokens/types'
import { deleteAccessToken } from '../../../api/tokens' 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 { export interface AccessTokenDeletionModalProps extends ModalVisibilityProps {
token: AccessToken token: AccessToken
@ -27,6 +27,7 @@ export interface AccessTokenDeletionModalProps extends ModalVisibilityProps {
*/ */
export const AccessTokenDeletionModal: React.FC<AccessTokenDeletionModalProps> = ({ show, token, onHide }) => { export const AccessTokenDeletionModal: React.FC<AccessTokenDeletionModalProps> = ({ show, token, onHide }) => {
useTranslation() useTranslation()
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
const onConfirmDelete = useCallback(() => { const onConfirmDelete = useCallback(() => {
deleteAccessToken(token.keyId) deleteAccessToken(token.keyId)
@ -43,7 +44,7 @@ export const AccessTokenDeletionModal: React.FC<AccessTokenDeletionModalProps> =
}) })
.catch(showErrorNotification('profile.modal.deleteAccessToken.failed')) .catch(showErrorNotification('profile.modal.deleteAccessToken.failed'))
.finally(() => onHide?.()) .finally(() => onHide?.())
}, [token, onHide]) }, [token.keyId, token.label, showErrorNotification, dispatchUiNotification, onHide])
return ( return (
<CommonModal <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 * 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 { ShowIf } from '../../common/show-if/show-if'
import { AccessTokenListEntry } from './access-token-list-entry' import { AccessTokenListEntry } from './access-token-list-entry'
import { AccessTokenCreationForm } from './access-token-creation-form/access-token-creation-form' 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 { export interface AccessTokenUpdateProps {
onUpdateList: () => void onUpdateList: () => void
@ -23,6 +23,7 @@ export interface AccessTokenUpdateProps {
export const ProfileAccessTokens: React.FC = () => { export const ProfileAccessTokens: React.FC = () => {
useTranslation() useTranslation()
const [accessTokens, setAccessTokens] = useState<AccessToken[]>([]) const [accessTokens, setAccessTokens] = useState<AccessToken[]>([])
const { showErrorNotification } = useUiNotifications()
const refreshAccessTokens = useCallback(() => { const refreshAccessTokens = useCallback(() => {
getAccessTokenList() getAccessTokenList()
@ -30,7 +31,7 @@ export const ProfileAccessTokens: React.FC = () => {
setAccessTokens(tokens) setAccessTokens(tokens)
}) })
.catch(showErrorNotification('profile.accessTokens.loadingFailed')) .catch(showErrorNotification('profile.accessTokens.loadingFailed'))
}, []) }, [showErrorNotification])
useEffect(() => { useEffect(() => {
refreshAccessTokens() refreshAccessTokens()

View file

@ -12,7 +12,7 @@ import { Button, Modal } from 'react-bootstrap'
import { CountdownButton } from '../../common/countdown-button/countdown-button' import { CountdownButton } from '../../common/countdown-button/countdown-button'
import { deleteUser } from '../../../api/me' import { deleteUser } from '../../../api/me'
import { clearUser } from '../../../redux/user/methods' 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. * Confirmation modal for deleting your account.
@ -22,6 +22,7 @@ import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui
*/ */
export const AccountDeletionModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => { export const AccountDeletionModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation() useTranslation()
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
const deleteUserAccount = useCallback(() => { const deleteUserAccount = useCallback(() => {
deleteUser() deleteUser()
@ -39,7 +40,7 @@ export const AccountDeletionModal: React.FC<ModalVisibilityProps> = ({ show, onH
onHide() onHide()
} }
}) })
}, [onHide]) }, [dispatchUiNotification, onHide, showErrorNotification])
return ( return (
<CommonModal show={show} title={'profile.modal.deleteUser.message'} onHide={onHide} showCloseButton={true}> <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 { Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { doLocalPasswordChange } from '../../../api/auth/local' import { doLocalPasswordChange } from '../../../api/auth/local'
import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useOnInputChange } from '../../../hooks/common/use-on-input-change' import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
import { NewPasswordField } from '../../common/fields/new-password-field' import { NewPasswordField } from '../../common/fields/new-password-field'
import { PasswordAgainField } from '../../common/fields/password-again-field' import { PasswordAgainField } from '../../common/fields/password-again-field'
import { CurrentPasswordField } from '../../common/fields/current-password-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. * Profile page section for changing the password when using internal login.
*/ */
export const ProfileChangePassword: React.FC = () => { export const ProfileChangePassword: React.FC = () => {
useTranslation() useTranslation()
const { showErrorNotification, dispatchUiNotification } = useUiNotifications()
const [oldPassword, setOldPassword] = useState('') const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [newPasswordAgain, setNewPasswordAgain] = useState('') const [newPasswordAgain, setNewPasswordAgain] = useState('')
@ -34,11 +35,11 @@ export const ProfileChangePassword: React.FC = () => {
(event: FormEvent) => { (event: FormEvent) => {
event.preventDefault() event.preventDefault()
doLocalPasswordChange(oldPassword, newPassword) doLocalPasswordChange(oldPassword, newPassword)
.then(() => { .then(() =>
return dispatchUiNotification('profile.changePassword.successTitle', 'profile.changePassword.successText', { dispatchUiNotification('profile.changePassword.successTitle', 'profile.changePassword.successText', {
icon: 'check' icon: 'check'
}) })
}) )
.catch(showErrorNotification('profile.changePassword.failed')) .catch(showErrorNotification('profile.changePassword.failed'))
.finally(() => { .finally(() => {
if (formRef.current) { if (formRef.current) {
@ -49,7 +50,7 @@ export const ProfileChangePassword: React.FC = () => {
setNewPasswordAgain('') setNewPasswordAgain('')
}) })
}, },
[oldPassword, newPassword] [oldPassword, newPassword, showErrorNotification, dispatchUiNotification]
) )
const ready = useMemo(() => { 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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -11,9 +11,9 @@ import { Trans, useTranslation } from 'react-i18next'
import { updateDisplayName } from '../../../api/me' import { updateDisplayName } from '../../../api/me'
import { fetchAndSetUser } from '../../login-page/auth/utils' import { fetchAndSetUser } from '../../login-page/auth/utils'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { DisplayNameField } from '../../common/fields/display-name-field' import { DisplayNameField } from '../../common/fields/display-name-field'
import { useOnInputChange } from '../../../hooks/common/use-on-input-change' 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. * Profile page section for changing the current display name.
@ -22,6 +22,7 @@ export const ProfileDisplayName: React.FC = () => {
useTranslation() useTranslation()
const userName = useApplicationState((state) => state.user?.displayName) const userName = useApplicationState((state) => state.user?.displayName)
const [displayName, setDisplayName] = useState(userName ?? '') const [displayName, setDisplayName] = useState(userName ?? '')
const { showErrorNotification } = useUiNotifications()
const onChangeDisplayName = useOnInputChange(setDisplayName) const onChangeDisplayName = useOnInputChange(setDisplayName)
const onSubmitNameChange = useCallback( const onSubmitNameChange = useCallback(
@ -31,7 +32,7 @@ export const ProfileDisplayName: React.FC = () => {
.then(fetchAndSetUser) .then(fetchAndSetUser)
.catch(showErrorNotification('profile.changeDisplayNameFailed')) .catch(showErrorNotification('profile.changeDisplayNameFailed'))
}, },
[displayName] [displayName, showErrorNotification]
) )
const formSubmittable = useMemo(() => { const formSubmittable = useMemo(() => {

View file

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

View file

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

View file

@ -23,7 +23,7 @@ import { LandingLayout } from '../components/landing-layout/landing-layout'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import type { NextPage } from 'next' import type { NextPage } from 'next'
import { Redirect } from '../components/common/redirect' 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. * 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 [passwordAgain, setPasswordAgain] = useState('')
const [error, setError] = useState<RegisterErrorType>() const [error, setError] = useState<RegisterErrorType>()
const { dispatchUiNotification } = useUiNotifications()
const doRegisterSubmit = useCallback( const doRegisterSubmit = useCallback(
(event: FormEvent) => { (event: FormEvent) => {
doLocalRegister(username, displayName, password) doLocalRegister(username, displayName, password)
@ -55,7 +57,7 @@ export const RegisterPage: NextPage = () => {
}) })
event.preventDefault() event.preventDefault()
}, },
[username, displayName, password, router] [username, displayName, password, dispatchUiNotification, router]
) )
const ready = useMemo(() => { 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 * 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 { MotdModal } from '../../components/common/motd-modal/motd-modal'
import { AppBar, AppBarMode } from '../../components/editor-page/app-bar/app-bar' 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 { 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 { DocumentReadOnlyPageContent } from '../../components/document-read-only-page/document-read-only-page-content'
import { NoteAndAppTitleHead } from '../../components/layout/note-and-app-title-head' import { NoteAndAppTitleHead } from '../../components/layout/note-and-app-title-head'
import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary' import { NoteLoadingBoundary } from '../../components/common/note-loading-boundary/note-loading-boundary'
@ -23,7 +22,6 @@ export const DocumentReadOnlyPage: React.FC = () => {
<EditorToRendererCommunicatorContextProvider> <EditorToRendererCommunicatorContextProvider>
<NoteLoadingBoundary> <NoteLoadingBoundary>
<NoteAndAppTitleHead /> <NoteAndAppTitleHead />
<UiNotifications />
<MotdModal /> <MotdModal />
<div className={'d-flex flex-column mvh-100 bg-light'}> <div className={'d-flex flex-column mvh-100 bg-light'}>
<AppBar mode={AppBarMode.BASIC} /> <AppBar mode={AppBarMode.BASIC} />

View file

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

View file

@ -20,7 +20,6 @@ import { addRemoteOriginToHistoryEntry, historyEntryToHistoryEntryPutDto } from
import { Logger } from '../../utils/logger' import { Logger } from '../../utils/logger'
import type { HistoryEntry, HistoryEntryWithOrigin } from '../../api/history/types' import type { HistoryEntry, HistoryEntryWithOrigin } from '../../api/history/types'
import { HistoryEntryOrigin } from '../../api/history/types' import { HistoryEntryOrigin } from '../../api/history/types'
import { showErrorNotification } from '../ui-notifications/methods'
const log = new Logger('Redux > History') const log = new Logger('Redux > History')
@ -177,13 +176,6 @@ export const refreshHistoryState = async (): Promise<void> => {
setHistoryEntries(allEntries) 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. * 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 { EditorConfigReducer } from './editor/reducers'
import { DarkModeConfigReducer } from './dark-mode/reducers' import { DarkModeConfigReducer } from './dark-mode/reducers'
import { NoteDetailsReducer } from './note-details/reducer' import { NoteDetailsReducer } from './note-details/reducer'
import { UiNotificationReducer } from './ui-notifications/reducers'
import { RendererStatusReducer } from './renderer-status/reducers' import { RendererStatusReducer } from './renderer-status/reducers'
import type { ApplicationState } from './application-state' import type { ApplicationState } from './application-state'
import { RealtimeReducer } from './realtime/reducers' import { RealtimeReducer } from './realtime/reducers'
@ -26,7 +25,6 @@ export const allReducers: Reducer<ApplicationState> = combineReducers<Applicatio
editorConfig: EditorConfigReducer, editorConfig: EditorConfigReducer,
darkMode: DarkModeConfigReducer, darkMode: DarkModeConfigReducer,
noteDetails: NoteDetailsReducer, noteDetails: NoteDetailsReducer,
uiNotifications: UiNotificationReducer,
rendererStatus: RendererStatusReducer, rendererStatus: RendererStatusReducer,
realtime: RealtimeReducer 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[]