mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-26 11:43:59 -05:00
improve: Move notifications from redux into context
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
b797f07aa5
commit
03d87f59f8
38 changed files with 362 additions and 376 deletions
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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])
|
||||
}
|
|
@ -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(() => {
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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')}>
|
||||
|
|
29
src/components/notifications/types.ts
Normal file
29
src/components/notifications/types.ts
Normal 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
|
||||
}
|
114
src/components/notifications/ui-notification-boundary.tsx
Normal file
114
src/components/notifications/ui-notification-boundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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} />
|
||||
|
|
2
src/redux/application-state.d.ts
vendored
2
src/redux/application-state.d.ts
vendored
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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[]
|
Loading…
Reference in a new issue