diff --git a/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx b/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx index fe7bced10..b46d634a1 100644 --- a/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx +++ b/src/components/editor-page/document-bar/permissions/permission-entry-special-group.tsx @@ -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 = ({ 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 { setGroupPermission(noteId, type, true) @@ -42,7 +43,7 @@ export const PermissionEntrySpecialGroup: React.FC { removeGroupPermission(noteId, type) @@ -50,7 +51,7 @@ export const PermissionEntrySpecialGroup: React.FC { switch (type) { diff --git a/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx b/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx index a67ca9395..515debb41 100644 --- a/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx +++ b/src/components/editor-page/document-bar/permissions/permission-entry-user.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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) diff --git a/src/components/editor-page/document-bar/permissions/permission-section-owner.tsx b/src/components/editor-page/document-bar/permissions/permission-section-owner.tsx index 83fd33509..ac5073725 100644 --- a/src/components/editor-page/document-bar/permissions/permission-section-owner.tsx +++ b/src/components/editor-page/document-bar/permissions/permission-section-owner.tsx @@ -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 ( diff --git a/src/components/editor-page/document-bar/permissions/permission-section-users.tsx b/src/components/editor-page/document-bar/permissions/permission-section-users.tsx index acc9e0994..c17d93292 100644 --- a/src/components/editor-page/document-bar/permissions/permission-section-users.tsx +++ b/src/components/editor-page/document-bar/permissions/permission-section-users.tsx @@ -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) => ) @@ -32,7 +33,7 @@ export const PermissionSectionUsers: React.FC = () => { }) .catch(showErrorNotification('editor.modal.permissions.error')) }, - [noteId] + [noteId, showErrorNotification] ) return ( diff --git a/src/components/editor-page/document-bar/revisions/revision-list-entry.tsx b/src/components/editor-page/document-bar/revisions/revision-list-entry.tsx index 4749fceac..f5b41a9d0 100644 --- a/src/components/editor-page/document-bar/revisions/revision-list-entry.tsx +++ b/src/components/editor-page/document-bar/revisions/revision-list-entry.tsx @@ -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 = ({ active, onSelect, revision }) => { useTranslation() + const { showErrorNotification } = useUiNotifications() const revisionCreationTime = useMemo(() => { return DateTime.fromISO(revision.createdAt).toFormat('DDDD T') diff --git a/src/components/editor-page/document-bar/revisions/revision-modal-footer.tsx b/src/components/editor-page/document-bar/revisions/revision-modal-footer.tsx index 2f1cd22a5..1b32e1655 100644 --- a/src/components/editor-page/document-bar/revisions/revision-modal-footer.tsx +++ b/src/components/editor-page/document-bar/revisions/revision-modal-footer.tsx @@ -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 { 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 diff --git a/src/components/editor-page/editor-page-content.tsx b/src/components/editor-page/editor-page-content.tsx index 4b8f53c58..541a61257 100644 --- a/src/components/editor-page/editor-page-content.tsx +++ b/src/components/editor-page/editor-page-content.tsx @@ -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 ( -
diff --git a/src/components/editor-page/editor-pane/hooks/use-handle-upload.tsx b/src/components/editor-page/editor-pane/hooks/use-handle-upload.tsx index e197ce6e9..883af2ed9 100644 --- a/src/components/editor-page/editor-pane/hooks/use-handle-upload.tsx +++ b/src/components/editor-page/editor-pane/hooks/use-handle-upload.tsx @@ -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] + ) } diff --git a/src/components/editor-page/sidebar/delete-note-sidebar-entry/delete-note-sidebar-entry.tsx b/src/components/editor-page/sidebar/delete-note-sidebar-entry/delete-note-sidebar-entry.tsx index 9777ca9c7..4cf28807a 100644 --- a/src/components/editor-page/sidebar/delete-note-sidebar-entry/delete-note-sidebar-entry.tsx +++ b/src/components/editor-page/sidebar/delete-note-sidebar-entry/delete-note-sidebar-entry.tsx @@ -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 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 diff --git a/src/components/editor-page/sidebar/specific-sidebar-entries/pin-note-sidebar-entry.tsx b/src/components/editor-page/sidebar/specific-sidebar-entries/pin-note-sidebar-entry.tsx index 647e4c6e4..8680eb8be 100644 --- a/src/components/editor-page/sidebar/specific-sidebar-entries/pin-note-sidebar-entry.tsx +++ b/src/components/editor-page/sidebar/specific-sidebar-entries/pin-note-sidebar-entry.tsx @@ -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 = ({ 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 = ({ class const onPinClicked = useCallback(() => { toggleHistoryEntryPinning(id).catch(showErrorNotification('landing.history.error.updateEntry.text')) - }, [id]) + }, [id, showErrorNotification]) return ( 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( () => 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) { diff --git a/src/components/history-page/history-toolbar/clear-history-button.tsx b/src/components/history-page/history-toolbar/clear-history-button.tsx index a9d9c3de0..f340d59eb 100644 --- a/src/components/history-page/history-toolbar/clear-history-button.tsx +++ b/src/components/history-page/history-toolbar/clear-history-button.tsx @@ -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 ( diff --git a/src/components/history-page/history-toolbar/history-refresh-button.tsx b/src/components/history-page/history-toolbar/history-refresh-button.tsx index 0f43aa825..ae47dfeb7 100644 --- a/src/components/history-page/history-toolbar/history-refresh-button.tsx +++ b/src/components/history-page/history-toolbar/history-refresh-button.tsx @@ -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 ( ) }), - [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 = ({ ) }) - }, [contentI18nKey, contentI18nOptions, t]) + }, [notification.contentI18nKey, notification.contentI18nOptions, t]) return ( - + - - + + - + {formattedCreatedAtDate} @@ -128,7 +113,7 @@ export const UiNotificationToast: React.FC = ({ diff --git a/src/components/notifications/ui-notifications.tsx b/src/components/notifications/ui-notifications.tsx index f04955012..29b8fa2f5 100644 --- a/src/components/notifications/ui-notifications.tsx +++ b/src/components/notifications/ui-notifications.tsx @@ -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 = ({ notifications }) => { const notificationElements = useMemo(() => { - return notifications.map((notification, notificationIndex) => ( - - )) + return notifications + .sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp) + .map((notification) => ) }, [notifications]) return ( diff --git a/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-on-create-token.ts b/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-on-create-token.ts index 7642b153e..f4c70776c 100644 --- a/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-on-create-token.ts +++ b/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-on-create-token.ts @@ -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] ) } diff --git a/src/components/profile-page/access-tokens/access-token-deletion-modal.tsx b/src/components/profile-page/access-tokens/access-token-deletion-modal.tsx index acbfff8cd..00e716b2a 100644 --- a/src/components/profile-page/access-tokens/access-token-deletion-modal.tsx +++ b/src/components/profile-page/access-tokens/access-token-deletion-modal.tsx @@ -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 = ({ show, token, onHide }) => { useTranslation() + const { showErrorNotification, dispatchUiNotification } = useUiNotifications() const onConfirmDelete = useCallback(() => { deleteAccessToken(token.keyId) @@ -43,7 +44,7 @@ export const AccessTokenDeletionModal: React.FC = }) .catch(showErrorNotification('profile.modal.deleteAccessToken.failed')) .finally(() => onHide?.()) - }, [token, onHide]) + }, [token.keyId, token.label, showErrorNotification, dispatchUiNotification, onHide]) return ( void @@ -23,6 +23,7 @@ export interface AccessTokenUpdateProps { export const ProfileAccessTokens: React.FC = () => { useTranslation() const [accessTokens, setAccessTokens] = useState([]) + 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() diff --git a/src/components/profile-page/account-management/account-deletion-modal.tsx b/src/components/profile-page/account-management/account-deletion-modal.tsx index 57de04a6a..6232d8c66 100644 --- a/src/components/profile-page/account-management/account-deletion-modal.tsx +++ b/src/components/profile-page/account-management/account-deletion-modal.tsx @@ -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 = ({ show, onHide }) => { useTranslation() + const { showErrorNotification, dispatchUiNotification } = useUiNotifications() const deleteUserAccount = useCallback(() => { deleteUser() @@ -39,7 +40,7 @@ export const AccountDeletionModal: React.FC = ({ show, onH onHide() } }) - }, [onHide]) + }, [dispatchUiNotification, onHide, showErrorNotification]) return ( diff --git a/src/components/profile-page/settings/profile-change-password.tsx b/src/components/profile-page/settings/profile-change-password.tsx index 93ffcbfc6..7f6426a97 100644 --- a/src/components/profile-page/settings/profile-change-password.tsx +++ b/src/components/profile-page/settings/profile-change-password.tsx @@ -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(() => { diff --git a/src/components/profile-page/settings/profile-display-name.tsx b/src/components/profile-page/settings/profile-display-name.tsx index e98292efa..f1b7dd1fb 100644 --- a/src/components/profile-page/settings/profile-display-name.tsx +++ b/src/components/profile-page/settings/profile-display-name.tsx @@ -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(() => { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 884a9191d..9fc8b3296 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -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 = ({ Component, pageProps }: AppProps) => - + + + diff --git a/src/pages/history.tsx b/src/pages/history.tsx index b4406416e..a8dcf74e2 100644 --- a/src/pages/history.tsx +++ b/src/pages/history.tsx @@ -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 ( diff --git a/src/pages/register.tsx b/src/pages/register.tsx index ac1c1863b..d82658116 100644 --- a/src/pages/register.tsx +++ b/src/pages/register.tsx @@ -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() + 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(() => { diff --git a/src/pages/s/[noteId].tsx b/src/pages/s/[noteId].tsx index 965c23185..3121f8b1b 100644 --- a/src/pages/s/[noteId].tsx +++ b/src/pages/s/[noteId].tsx @@ -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 = () => { -
diff --git a/src/redux/application-state.d.ts b/src/redux/application-state.d.ts index 9e98d9c00..abf64cffd 100644 --- a/src/redux/application-state.d.ts +++ b/src/redux/application-state.d.ts @@ -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 } diff --git a/src/redux/history/methods.ts b/src/redux/history/methods.ts index 7cb4d4106..624a52521 100644 --- a/src/redux/history/methods.ts +++ b/src/redux/history/methods.ts @@ -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 => { 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. */ diff --git a/src/redux/reducers.ts b/src/redux/reducers.ts index 61c5d2ca3..6f5368349 100644 --- a/src/redux/reducers.ts +++ b/src/redux/reducers.ts @@ -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 = combineReducers 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 -): Promise => { - 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' - }) - } diff --git a/src/redux/ui-notifications/reducers.ts b/src/redux/ui-notifications/reducers.ts deleted file mode 100644 index 802aca0a3..000000000 --- a/src/redux/ui-notifications/reducers.ts +++ /dev/null @@ -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 = ( - 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 -} diff --git a/src/redux/ui-notifications/types.ts b/src/redux/ui-notifications/types.ts deleted file mode 100644 index 51f55661e..000000000 --- a/src/redux/ui-notifications/types.ts +++ /dev/null @@ -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 { - type: UiNotificationActionType.DISPATCH_NOTIFICATION - notification: UiNotification - notificationIdCallback: (notificationId: number) => void -} - -export interface DismissUiNotificationAction extends Action { - type: UiNotificationActionType.DISMISS_NOTIFICATION - notificationId: number -} - -export type UiNotificationState = UiNotification[]