fix(frontend): make note details in redux optional

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-09-06 22:18:38 +02:00
parent 118f158ad1
commit 6698450461
50 changed files with 278 additions and 135 deletions

View file

@ -71,7 +71,7 @@ describe('Revision modal', () => {
cy.getByCypressId('sidebar.revision.modal').should('be.visible')
})
it('can download revisions', () => {
cy.intercept('GET', '/api/private/notes/mock-note/revisions/1', {
cy.intercept('GET', `/api/private/notes/${testNoteId}/revisions/1`, {
id: 1,
createdAt: defaultCreatedAt,
title: 'Features',
@ -86,7 +86,7 @@ describe('Revision modal', () => {
})
const downloadFolder = Cypress.config('downloadsFolder')
const fileName = `mock-note-${defaultCreatedAt.replace(/:/g, '_')}.md`
const fileName = `${testNoteId}-${defaultCreatedAt.replace(/:/g, '_')}.md`
const filePath = join(downloadFolder, fileName)
cy.getByCypressId('revision.modal.lists').contains(formattedDate).click()

View file

@ -15,6 +15,7 @@ import { CreateNonExistingNoteHint } from './create-non-existing-note-hint'
import { useLoadNoteFromServer } from './hooks/use-load-note-from-server'
import type { PropsWithChildren } from 'react'
import React, { useEffect, useMemo } from 'react'
import { unloadNote } from '../../../redux/note-details/methods'
const logger = new Logger('NoteLoadingBoundary')
@ -37,6 +38,13 @@ export const NoteLoadingBoundary: React.FC<PropsWithChildren<NoteIdProps>> = ({
loadNoteFromServer()
}, [loadNoteFromServer])
useEffect(
() => () => {
unloadNote()
},
[]
)
const errorComponent = useMemo(() => {
if (error === undefined) {
return null

View file

@ -15,17 +15,18 @@ import { useMemo } from 'react'
*/
export const useSendAdditionalConfigurationToRenderer = (rendererReady: boolean): void => {
const darkModePreference = useApplicationState((state) => state.darkMode.darkModePreference)
const newlinesAreBreaks = useApplicationState((state) => state.noteDetails.frontmatter.newlinesAreBreaks)
const newlinesAreBreaks = useApplicationState((state) => state.noteDetails?.frontmatter.newlinesAreBreaks)
useSendToRenderer(
useMemo(
() => ({
type: CommunicationMessageType.SET_ADDITIONAL_CONFIGURATION,
darkModePreference: darkModePreference,
newLinesAreBreaks: newlinesAreBreaks
}),
[darkModePreference, newlinesAreBreaks]
),
useMemo(() => {
return newlinesAreBreaks === undefined
? undefined
: {
type: CommunicationMessageType.SET_ADDITIONAL_CONFIGURATION,
darkModePreference: darkModePreference,
newLinesAreBreaks: newlinesAreBreaks
}
}, [darkModePreference, newlinesAreBreaks]),
rendererReady
)
}

View file

@ -22,6 +22,11 @@ export const DocumentInfobar: React.FC = () => {
// TODO Check permissions ("writability") of note and show edit link depending on that.
const linkTitle = useTranslatedText('views.readOnly.editNote')
if (noteDetails === null) {
return null
}
return (
<div className={`d-flex flex-row my-3 ${styles['document-infobar']}`}>
<div className={'col-md'}>&nbsp;</div>

View file

@ -12,7 +12,6 @@ import { useCodemirrorReferenceContext } from '../../../change-content-context/c
import type { CursorSelection } from '../../tool-bar/formatters/types/cursor-selection'
import { useHandleUpload } from '../use-handle-upload'
import { findRegexMatchInText } from './find-regex-match-in-text'
import { Optional } from '@mrdrogdrog/optional'
import { useCallback } from 'react'
const log = new Logger('useOnImageUpload')
@ -43,12 +42,7 @@ export const useOnImageUploadFromRenderer = (): void => {
.then((result) => result.blob())
.then((blob) => {
const file = new File([blob], fileName, { type: blob.type })
const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex)
.flatMap((actualLineIndex) => {
const lineOffset = getGlobalState().noteDetails.startOfContentLineOffset
return findPlaceholderInMarkdownContent(actualLineIndex + lineOffset, placeholderIndexInLine)
})
.orElse({} as ExtractResult)
const { cursorSelection, alt, title } = findPlaceholderInMarkdownContent(lineIndex, placeholderIndexInLine)
handleUpload(codeMirrorReference, file, cursorSelection, alt, title)
})
.catch((error) => log.error(error))
@ -71,11 +65,30 @@ export interface ExtractResult {
* @param replacementIndexInLine If multiple image placeholders are present in the target line then this number describes the index of the wanted placeholder.
* @return the calculated start and end position or undefined if no position could be determined
*/
const findPlaceholderInMarkdownContent = (lineIndex: number, replacementIndexInLine = 0): Optional<ExtractResult> => {
const findPlaceholderInMarkdownContent = (
lineIndex: number | undefined,
replacementIndexInLine: number | undefined
): ExtractResult => {
if (lineIndex === undefined) {
return {}
}
const noteDetails = getGlobalState().noteDetails
if (!noteDetails) {
return {}
}
const currentMarkdownContentLines = noteDetails.markdownContent.lines
return Optional.ofNullable(noteDetails.markdownContent.lineStartIndexes[lineIndex]).map((startIndexOfLine) =>
findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], startIndexOfLine, replacementIndexInLine)
const actualLineIndex = noteDetails.startOfContentLineOffset + lineIndex
const lineStartIndex = noteDetails.markdownContent.lineStartIndexes[actualLineIndex]
if (lineStartIndex === undefined) {
return {}
}
return (
findImagePlaceholderInLine(
currentMarkdownContentLines[actualLineIndex],
lineStartIndex,
replacementIndexInLine ?? 0
) ?? {}
)
}

View file

@ -50,7 +50,10 @@ export const useHandleUpload = (): handleUploadSignature => {
: t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})`
const noteId = getGlobalState().noteDetails.id
const noteId = getGlobalState().noteDetails?.id
if (noteId === undefined) {
return
}
changeContent(({ currentSelection }) => {
return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false)
})

View file

@ -24,24 +24,23 @@ const calculateLineBasedPosition = (absolutePosition: number, lineStartIndexes:
* Returns the line+character based position of the to-cursor, if available.
*/
export const useLineBasedToPosition = (): LineBasedPosition | undefined => {
const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes)
const selection = useApplicationState((state) => state.noteDetails.selection)
const lineStartIndexes = useApplicationState((state) => state.noteDetails?.markdownContent.lineStartIndexes ?? [])
const selectionTo = useApplicationState((state) => state.noteDetails?.selection.to)
return useMemo(() => {
const to = selection.to
if (to === undefined) {
if (selectionTo === undefined) {
return undefined
}
return calculateLineBasedPosition(to, lineStartIndexes)
}, [selection.to, lineStartIndexes])
return calculateLineBasedPosition(selectionTo, lineStartIndexes)
}, [selectionTo, lineStartIndexes])
}
/**
* Returns the line+character based position of the from-cursor.
*/
export const useLineBasedFromPosition = (): LineBasedPosition => {
const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes)
const selection = useApplicationState((state) => state.noteDetails.selection)
const lineStartIndexes = useApplicationState((state) => state.noteDetails?.markdownContent.lineStartIndexes ?? [])
const selection = useApplicationState((state) => state.noteDetails?.selection ?? { from: 0 })
return useMemo(() => {
return calculateLineBasedPosition(selection.from, lineStartIndexes)

View file

@ -32,7 +32,7 @@ export const useRealtimeConnection = (): MessageTransporter => {
if (isMockMode) {
logger.debug('Creating Loopback connection...')
messageTransporter.setAdapter(
new MockedBackendTransportAdapter(getGlobalState().noteDetails.markdownContent.plain)
new MockedBackendTransportAdapter(getGlobalState().noteDetails?.markdownContent.plain ?? '')
)
} else if (websocketUrl) {
logger.debug(`Connecting to ${websocketUrl.toString()}`)

View file

@ -4,9 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { store } from '../../../../../redux'
import { setRealtimeUsers } from '../../../../../redux/realtime/methods'
import { RealtimeStatusActionType } from '../../../../../redux/realtime/types'
import { resetRealtimeStatus, setRealtimeUsers } from '../../../../../redux/realtime/methods'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
@ -43,12 +41,5 @@ export const useReceiveRealtimeUsers = (messageTransporter: MessageTransporter):
}
}, [isConnected, messageTransporter])
useEffect(
() => () => {
store.dispatch({
type: RealtimeStatusActionType.RESET_REALTIME_STATUS
})
},
[]
)
useEffect(() => () => resetRealtimeStatus(), [])
}

View file

@ -14,7 +14,7 @@ const LOCAL_FALLBACK_URL = 'ws://localhost:8080/realtime/'
* Provides the URL for the realtime endpoint.
*/
export const useWebsocketUrl = (): URL | undefined => {
const noteId = useApplicationState((state) => state.noteDetails.id)
const noteId = useApplicationState((state) => state.noteDetails?.id)
const baseUrl = useBaseUrl()
const websocketUrl = useMemo(() => {
@ -33,7 +33,7 @@ export const useWebsocketUrl = (): URL | undefined => {
}, [baseUrl])
return useMemo(() => {
if (noteId === '') {
if (noteId === '' || noteId === undefined) {
return
}
const url = new URL(websocketUrl)

View file

@ -13,7 +13,7 @@ import { Trans, useTranslation } from 'react-i18next'
export const NumberOfLinesInDocumentInfo: React.FC = () => {
useTranslation()
const linesInDocument = useApplicationState((state) => state.noteDetails.markdownContent.lines.length)
const linesInDocument = useApplicationState((state) => state.noteDetails?.markdownContent.lines.length ?? 0)
const translationOptions = useMemo(() => ({ lines: linesInDocument }), [linesInDocument])
return (

View file

@ -16,7 +16,7 @@ export const RemainingCharactersInfo: React.FC = () => {
const { t } = useTranslation()
const maxDocumentLength = useFrontendConfig().maxDocumentLength
const contentLength = useApplicationState((state) => state.noteDetails.markdownContent.plain.length)
const contentLength = useApplicationState((state) => state.noteDetails?.markdownContent.plain.length ?? 0)
const remainingCharacters = useMemo(() => maxDocumentLength - contentLength, [contentLength, maxDocumentLength])
const remainingCharactersClass = useMemo(() => {

View file

@ -14,10 +14,10 @@ import { Trans, useTranslation } from 'react-i18next'
export const SelectedCharacters: React.FC = () => {
useTranslation()
const selection = useApplicationState((state) => state.noteDetails.selection)
const selection = useApplicationState((state) => state.noteDetails?.selection)
const count = useMemo(
() => (selection.to === undefined ? undefined : selection.to - selection.from),
[selection.from, selection.to]
() => (selection === undefined || selection.to === undefined ? undefined : selection.to - selection.from),
[selection]
)
const countTranslationOptions = useMemo(() => ({ count }), [count])

View file

@ -16,16 +16,16 @@ import { useEffect, useRef } from 'react'
* The entry is updated when the title or tags of the note change.
*/
export const useUpdateLocalHistoryEntry = (): void => {
const id = useApplicationState((state) => state.noteDetails.id)
const id = useApplicationState((state) => state.noteDetails?.id)
const userExists = useApplicationState((state) => !!state.user)
const currentNoteTitle = useApplicationState((state) => state.noteDetails.title)
const currentNoteTags = useApplicationState((state) => state.noteDetails.frontmatter.tags)
const currentNoteTitle = useApplicationState((state) => state.noteDetails?.title ?? '')
const currentNoteTags = useApplicationState((state) => state.noteDetails?.frontmatter.tags ?? [])
const lastNoteTitle = useRef('')
const lastNoteTags = useRef<string[]>([])
useEffect(() => {
if (userExists) {
if (userExists || id === undefined) {
return
}
if (currentNoteTitle === lastNoteTitle.current && equal(currentNoteTags, lastNoteTags.current)) {

View file

@ -19,8 +19,12 @@ export const useOnScrollWithLineOffset = (onScroll: ScrollCallback | undefined):
return undefined
} else {
return (scrollState: ScrollState) => {
const noteDetails = getGlobalState().noteDetails
if (noteDetails === null) {
return undefined
}
onScroll({
firstLineInView: scrollState.firstLineInView + getGlobalState().noteDetails.startOfContentLineOffset,
firstLineInView: scrollState.firstLineInView + noteDetails.startOfContentLineOffset,
scrolledPercentage: scrollState.scrolledPercentage
})
}

View file

@ -14,9 +14,9 @@ import { useMemo } from 'react'
* @return the adjusted scroll state without the line offset
*/
export const useScrollStateWithoutLineOffset = (scrollState: ScrollState | undefined): ScrollState | undefined => {
const lineOffset = useApplicationState((state) => state.noteDetails.startOfContentLineOffset)
const lineOffset = useApplicationState((state) => state.noteDetails?.startOfContentLineOffset)
return useMemo(() => {
return scrollState === undefined
return scrollState === undefined || lineOffset === undefined
? undefined
: {
firstLineInView: scrollState.firstLineInView - lineOffset,

View file

@ -28,10 +28,14 @@ export type RendererPaneProps = Omit<
*/
export const RendererPane: React.FC<RendererPaneProps> = ({ scrollState, onScroll, ...props }) => {
const trimmedContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
const noteType: NoteType = useApplicationState((state) => state.noteDetails.frontmatter.type)
const noteType = useApplicationState((state) => state.noteDetails?.frontmatter.type)
const adjustedOnScroll = useOnScrollWithLineOffset(onScroll)
const adjustedScrollState = useScrollStateWithoutLineOffset(scrollState)
if (!noteType) {
return null
}
return (
<RendererIframe
{...props}

View file

@ -24,13 +24,16 @@ const validAliasRegex = /^[a-z0-9_-]*$/
*/
export const AliasesAddForm: React.FC = () => {
const { showErrorNotification } = useUiNotifications()
const noteId = useApplicationState((state) => state.noteDetails.id)
const noteId = useApplicationState((state) => state.noteDetails?.id)
const isOwner = useIsOwner()
const [newAlias, setNewAlias] = useState('')
const onAddAlias = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (noteId === undefined) {
return
}
addAlias(noteId, newAlias)
.then(updateMetadata)
.catch(showErrorNotification('editor.modal.aliases.errorAddingAlias'))

View file

@ -12,12 +12,14 @@ import React, { Fragment, useMemo } from 'react'
* Renders the list of aliases.
*/
export const AliasesList: React.FC = () => {
const aliases = useApplicationState((state: ApplicationState) => state.noteDetails.aliases)
const aliases = useApplicationState((state: ApplicationState) => state.noteDetails?.aliases)
const aliasesDom = useMemo(() => {
return aliases
.sort((a, b) => a.name.localeCompare(b.name))
.map((alias) => <AliasesListEntry alias={alias} key={alias.name} />)
return aliases === undefined
? null
: aliases
.sort((a, b) => a.name.localeCompare(b.name))
.map((alias) => <AliasesListEntry alias={alias} key={alias.name} />)
}, [aliases])
return <Fragment>{aliasesDom}</Fragment>

View file

@ -26,11 +26,14 @@ import { Trans, useTranslation } from 'react-i18next'
export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarEntryProps>> = ({ hide, className }) => {
useTranslation()
const router = useRouter()
const noteId = useApplicationState((state) => state.noteDetails.id)
const noteId = useApplicationState((state) => state.noteDetails?.id)
const [modalVisibility, showModal, closeModal] = useBooleanState()
const { showErrorNotification } = useUiNotifications()
const deleteNoteAndCloseDialog = useCallback(() => {
if (noteId === undefined) {
return
}
deleteNote(noteId)
.then(() => router.push('/history'))
.catch(showErrorNotification('landing.history.error.deleteNote.text'))

View file

@ -20,7 +20,11 @@ export const ExportMarkdownSidebarEntry: React.FC = () => {
const { t } = useTranslation()
const markdownContent = useNoteMarkdownContent()
const onClick = useCallback(() => {
const sanitized = sanitize(getGlobalState().noteDetails.title)
const title = getGlobalState().noteDetails?.title
if (title === undefined) {
return
}
const sanitized = sanitize(title)
download(markdownContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown')
}, [markdownContent, t])

View file

@ -12,7 +12,7 @@ import { People as IconPeople } from 'react-bootstrap-icons'
* Renders an info line about the number of contributors for the note.
*/
export const NoteInfoLineContributors: React.FC = () => {
const contributors = useApplicationState((state) => state.noteDetails.editedBy.length)
const contributors = useApplicationState((state) => state.noteDetails?.editedBy.length ?? 0)
return (
<SidebarMenuInfoEntry titleI18nKey={'editor.noteInfo.contributors'} icon={IconPeople}>

View file

@ -16,10 +16,13 @@ import { useTranslation } from 'react-i18next'
*/
export const NoteInfoLineCreatedAt: React.FC = () => {
useTranslation()
const noteCreateTime = useApplicationState((state) => state.noteDetails.createdAt)
const noteCreateDateTime = useMemo(() => DateTime.fromSeconds(noteCreateTime), [noteCreateTime])
const noteCreateTime = useApplicationState((state) => state.noteDetails?.createdAt)
const noteCreateDateTime = useMemo(
() => (noteCreateTime === undefined ? undefined : DateTime.fromSeconds(noteCreateTime)),
[noteCreateTime]
)
return (
return !noteCreateDateTime ? null : (
<SidebarMenuInfoEntry titleI18nKey={'editor.noteInfo.created'} icon={IconPlus}>
<TimeFromNow time={noteCreateDateTime} />
</SidebarMenuInfoEntry>

View file

@ -16,10 +16,13 @@ import { useTranslation } from 'react-i18next'
*/
export const NoteInfoLineUpdatedAt: React.FC = () => {
useTranslation()
const noteUpdateTime = useApplicationState((state) => state.noteDetails.updatedAt)
const noteUpdateDateTime = useMemo(() => DateTime.fromSeconds(noteUpdateTime), [noteUpdateTime])
const noteUpdateTime = useApplicationState((state) => state.noteDetails?.updatedAt)
const noteUpdateDateTime = useMemo(
() => (noteUpdateTime === undefined ? undefined : DateTime.fromSeconds(noteUpdateTime)),
[noteUpdateTime]
)
return (
return !noteUpdateDateTime ? null : (
<SidebarMenuInfoEntry titleI18nKey={'editor.noteInfo.lastUpdated'} icon={IconPencil}>
<TimeFromNow time={noteUpdateDateTime} />
</SidebarMenuInfoEntry>

View file

@ -15,7 +15,7 @@ import { Trans, useTranslation } from 'react-i18next'
*/
export const NoteInfoLineUpdatedBy: React.FC = () => {
useTranslation()
const noteUpdateUser = useApplicationState((state) => state.noteDetails.updateUsername)
const noteUpdateUser = useApplicationState((state) => state.noteDetails?.updateUsername)
const userBlock = useMemo(() => {
if (!noteUpdateUser) {

View file

@ -33,11 +33,14 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
type,
disabled
}) => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const noteId = useApplicationState((state) => state.noteDetails?.primaryAddress)
const { t } = useTranslation()
const { showErrorNotification } = useUiNotifications()
const onSetEntryReadOnly = useCallback(() => {
if (!noteId) {
return
}
setGroupPermission(noteId, type, false)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
@ -46,6 +49,9 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
}, [noteId, showErrorNotification, type])
const onSetEntryWriteable = useCallback(() => {
if (!noteId) {
return
}
setGroupPermission(noteId, type, true)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
@ -54,6 +60,9 @@ export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupPr
}, [noteId, showErrorNotification, type])
const onSetEntryDenied = useCallback(() => {
if (!noteId) {
return
}
removeGroupPermission(noteId, type)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)

View file

@ -31,10 +31,13 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps & Permission
entry,
disabled
}) => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const noteId = useApplicationState((state) => state.noteDetails?.primaryAddress)
const { showErrorNotification } = useUiNotifications()
const onRemoveEntry = useCallback(() => {
if (!noteId) {
return
}
removeUserPermission(noteId, entry.username)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
@ -43,6 +46,9 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps & Permission
}, [noteId, entry.username, showErrorNotification])
const onSetEntryReadOnly = useCallback(() => {
if (!noteId) {
return
}
setUserPermission(noteId, entry.username, false)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
@ -51,6 +57,9 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps & Permission
}, [noteId, entry.username, showErrorNotification])
const onSetEntryWriteable = useCallback(() => {
if (!noteId) {
return
}
setUserPermission(noteId, entry.username, true)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)

View file

@ -26,9 +26,13 @@ export const PermissionOwnerInfo: React.FC<PermissionOwnerInfoProps & Permission
onEditOwner,
disabled
}) => {
const noteOwner = useApplicationState((state) => state.noteDetails.permissions.owner)
const noteOwner = useApplicationState((state) => state.noteDetails?.permissions.owner)
const buttonTitle = useTranslatedText('editor.modal.permissions.ownerChange.button')
if (!noteOwner) {
return null
}
return (
<Fragment>
<UserAvatarForUsername username={noteOwner} />

View file

@ -19,7 +19,7 @@ import { Trans } from 'react-i18next'
* @param disabled If the user is not the owner, functionality is disabled.
*/
export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ disabled }) => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const noteId = useApplicationState((state) => state.noteDetails?.id)
const [changeOwner, setChangeOwner] = useState(false)
const { showErrorNotification } = useUiNotifications()
@ -29,6 +29,9 @@ export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ disa
const onOwnerChange = useCallback(
(newOwner: string) => {
if (!noteId) {
return
}
setNoteOwner(noteId, newOwner)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)

View file

@ -18,10 +18,13 @@ import { Trans, useTranslation } from 'react-i18next'
*/
export const PermissionSectionSpecialGroups: React.FC<PermissionDisabledProps> = ({ disabled }) => {
useTranslation()
const groupPermissions = useApplicationState((state) => state.noteDetails.permissions.sharedToGroups)
const groupPermissions = useApplicationState((state) => state.noteDetails?.permissions.sharedToGroups)
const isOwner = useIsOwner()
const specialGroupEntries = useMemo(() => {
if (!groupPermissions) {
return
}
const groupEveryone = groupPermissions.find((entry) => entry.groupName === (SpecialGroup.EVERYONE as string))
const groupLoggedIn = groupPermissions.find((entry) => entry.groupName === (SpecialGroup.LOGGED_IN as string))
@ -39,6 +42,10 @@ export const PermissionSectionSpecialGroups: React.FC<PermissionDisabledProps> =
}
}, [groupPermissions])
if (!specialGroupEntries) {
return null
}
return (
<Fragment>
<h5 className={'my-3'}>

View file

@ -20,11 +20,14 @@ import { Trans, useTranslation } from 'react-i18next'
*/
export const PermissionSectionUsers: React.FC<PermissionDisabledProps> = ({ disabled }) => {
useTranslation()
const userPermissions = useApplicationState((state) => state.noteDetails.permissions.sharedToUsers)
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const userPermissions = useApplicationState((state) => state.noteDetails?.permissions.sharedToUsers)
const noteId = useApplicationState((state) => state.noteDetails?.id)
const { showErrorNotification } = useUiNotifications()
const userEntries = useMemo(() => {
if (!userPermissions) {
return null
}
return userPermissions.map((entry) => (
<PermissionEntryUser key={entry.username} entry={entry} disabled={disabled} />
))
@ -32,6 +35,9 @@ export const PermissionSectionUsers: React.FC<PermissionDisabledProps> = ({ disa
const onAddEntry = useCallback(
(username: string) => {
if (!noteId) {
return
}
setUserPermission(noteId, username, false)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)

View file

@ -22,21 +22,24 @@ import { Trans, useTranslation } from 'react-i18next'
*/
export const PinNoteSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ className, hide }) => {
useTranslation()
const id = useApplicationState((state) => state.noteDetails.id)
const noteId = 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)
const entry = history.find((entry) => entry.identifier === noteId)
if (!entry) {
return false
}
return entry.pinStatus
}, [id, history])
}, [history, noteId])
const onPinClicked = useCallback(() => {
toggleHistoryEntryPinning(id).catch(showErrorNotification('landing.history.error.updateEntry.text'))
}, [id, showErrorNotification])
if (!noteId) {
return
}
toggleHistoryEntryPinning(noteId).catch(showErrorNotification('landing.history.error.updateEntry.text'))
}, [noteId, showErrorNotification])
return (
<SidebarButton

View file

@ -21,9 +21,12 @@ import { Trans } from 'react-i18next'
*/
export const RevisionDeleteModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
const { showErrorNotification } = useUiNotifications()
const noteId = useApplicationState((state) => state.noteDetails.id)
const noteId = useApplicationState((state) => state.noteDetails?.id)
const deleteAllRevisions = useCallback(() => {
if (!noteId) {
return
}
deleteRevisionsForNote(noteId).catch(showErrorNotification('editor.modal.deleteRevision.error')).finally(onHide)
}, [noteId, onHide, showErrorNotification])

View file

@ -27,8 +27,17 @@ export const RevisionModalBody = ({ onShowDeleteModal, onHide }: RevisionModal)
useTranslation()
const isOwner = useIsOwner()
const [selectedRevisionId, setSelectedRevisionId] = useState<number>()
const noteIdentifier = useApplicationState((state) => state.noteDetails.id)
const { value: revisions, error, loading } = useAsync(() => getAllRevisions(noteIdentifier), [noteIdentifier])
const noteId = useApplicationState((state) => state.noteDetails?.id)
const {
value: revisions,
error,
loading
} = useAsync(async () => {
if (!noteId) {
return []
}
return getAllRevisions(noteId)
}, [noteId])
const revisionLength = revisions?.length ?? 0
const enableDeleteRevisions = revisionLength > 1 && isOwner

View file

@ -37,7 +37,7 @@ export const RevisionModalFooter: React.FC<RevisionModalFooterProps> = ({
disableDeleteRevisions
}) => {
useTranslation()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const noteId = useApplicationState((state) => state.noteDetails?.id)
const { showErrorNotification } = useUiNotifications()
const onRevertToRevision = useCallback(() => {
@ -47,15 +47,15 @@ export const RevisionModalFooter: React.FC<RevisionModalFooterProps> = ({
}, [])
const onDownloadRevision = useCallback(() => {
if (selectedRevisionId === undefined) {
if (selectedRevisionId === undefined || noteId === undefined) {
return
}
getRevision(noteIdentifier, selectedRevisionId)
getRevision(noteId, selectedRevisionId)
.then((revision) => {
downloadRevision(noteIdentifier, revision)
downloadRevision(noteId, revision)
})
.catch(showErrorNotification(''))
}, [noteIdentifier, selectedRevisionId, showErrorNotification])
}, [noteId, selectedRevisionId, showErrorNotification])
const openDeleteModal = useCallback(() => {
onHide?.()

View file

@ -25,16 +25,16 @@ export interface RevisionViewerProps {
* @param allRevisions List of metadata for all available revisions.
*/
export const RevisionViewer: React.FC<RevisionViewerProps> = ({ selectedRevisionId }) => {
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const noteId = useApplicationState((state) => state.noteDetails?.id)
const darkModeEnabled = useDarkModeState()
const { value, error, loading } = useAsync(async () => {
if (selectedRevisionId === undefined) {
if (noteId === undefined || selectedRevisionId === undefined) {
throw new Error('No revision selected')
} else {
return await getRevision(noteIdentifier, selectedRevisionId)
return await getRevision(noteId, selectedRevisionId)
}
}, [selectedRevisionId, noteIdentifier])
}, [selectedRevisionId, noteId])
const previousRevisionContent = useMemo(() => {
return Optional.ofNullable(value)

View file

@ -24,13 +24,16 @@ export interface LinkFieldProps {
*/
export const NoteUrlField: React.FC<LinkFieldProps> = ({ type }) => {
const baseUrl = useBaseUrl()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const noteId = useApplicationState((state) => state.noteDetails?.id)
const url = useMemo(() => {
if (noteId === undefined) {
return undefined
}
const url = new URL(baseUrl)
url.pathname += `${type}/${noteIdentifier}`
url.pathname += `${type}/${noteId}`
return url.toString()
}, [baseUrl, noteIdentifier, type])
}, [baseUrl, noteId, type])
return <CopyableField content={url} shareOriginUrl={url} />
return !url ? null : <CopyableField content={url} shareOriginUrl={url} />
}

View file

@ -21,7 +21,11 @@ import { Trans, useTranslation } from 'react-i18next'
*/
export const ShareModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation()
const noteFrontmatter = useApplicationState((state) => state.noteDetails.frontmatter)
const noteFrontmatter = useApplicationState((state) => state.noteDetails?.frontmatter)
if (!noteFrontmatter) {
return null
}
return (
<CommonModal show={show} onHide={onHide} showCloseButton={true} titleI18nKey={'editor.modal.shareLink.title'}>

View file

@ -22,16 +22,17 @@ export const SlideShowPageContent: React.FC = () => {
const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
useTranslation()
const slideOptions = useApplicationState((state) => state.noteDetails.frontmatter.slideOptions)
const slideOptions = useApplicationState((state) => state.noteDetails?.frontmatter.slideOptions)
const rendererReady = useApplicationState((state) => state.rendererStatus.rendererReady)
useSendToRenderer(
useMemo(
() => ({
type: CommunicationMessageType.SET_SLIDE_OPTIONS,
slideOptions
}),
[slideOptions]
),
useMemo(() => {
return !slideOptions
? undefined
: {
type: CommunicationMessageType.SET_SLIDE_OPTIONS,
slideOptions
}
}, [slideOptions]),
rendererReady
)

View file

@ -31,8 +31,13 @@ export const useSetCheckboxInEditor = () => {
return useCallback(
({ lineInMarkdown, newCheckedState }: TaskCheckedEventPayload): void => {
const noteDetails = store.getState().noteDetails
if (!noteDetails) {
return
}
changeEditorContent?.(({ markdownContent }) => {
const correctedLineIndex = lineInMarkdown + store.getState().noteDetails.startOfContentLineOffset
const correctedLineIndex = lineInMarkdown + noteDetails.startOfContentLineOffset
const edits = findCheckBox(markdownContent, correctedLineIndex)
.map(([startIndex, endIndex]) => createCheckboxContentEdit(startIndex, endIndex, newCheckedState))
.orElse([])

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from './use-application-state'
import type { NotePermissions } from '@hedgedoc/commons'
import { userIsOwner } from '@hedgedoc/commons'
import { useMemo } from 'react'
@ -15,7 +14,7 @@ import { useMemo } from 'react'
*/
export const useIsOwner = (): boolean => {
const me: string | undefined = useApplicationState((state) => state.user?.username)
const permissions: NotePermissions = useApplicationState((state) => state.noteDetails.permissions)
const permissions = useApplicationState((state) => state.noteDetails?.permissions)
return useMemo(() => userIsOwner(permissions, me), [permissions, me])
return useMemo(() => (permissions === undefined ? false : userIsOwner(permissions, me)), [permissions, me])
}

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from './use-application-state'
import type { NotePermissions } from '@hedgedoc/commons'
import { userCanEdit } from '@hedgedoc/commons'
import { useMemo } from 'react'
@ -15,7 +14,7 @@ import { useMemo } from 'react'
*/
export const useMayEdit = (): boolean => {
const me: string | undefined = useApplicationState((state) => state.user?.username)
const permissions: NotePermissions = useApplicationState((state) => state.noteDetails.permissions)
const permissions = useApplicationState((state) => state.noteDetails?.permissions)
return useMemo(() => userCanEdit(permissions, me), [permissions, me])
return useMemo(() => (!permissions ? false : userCanEdit(permissions, me)), [permissions, me])
}

View file

@ -11,5 +11,5 @@ import { useApplicationState } from './use-application-state'
* @return The markdown content of the note
*/
export const useNoteMarkdownContent = (): string => {
return useApplicationState((state) => state.noteDetails.markdownContent.plain)
return useApplicationState((state) => state.noteDetails?.markdownContent.plain ?? '')
}

View file

@ -14,7 +14,7 @@ import { useMemo } from 'react'
*/
export const useNoteTitle = (): string => {
const untitledNote = useTranslatedText('editor.untitledNote')
const noteTitle = useApplicationState((state) => state.noteDetails.title)
const noteTitle = useApplicationState((state) => state.noteDetails?.title)
return useMemo(() => (noteTitle === '' ? untitledNote : noteTitle), [noteTitle, untitledNote])
return useMemo(() => (!noteTitle ? untitledNote : noteTitle), [noteTitle, untitledNote])
}

View file

@ -14,13 +14,23 @@ import { useMemo } from 'react'
*/
export const useTrimmedNoteMarkdownContentWithoutFrontmatter = (): string[] => {
const maxLength = useFrontendConfig().maxDocumentLength
const markdownContent = useApplicationState((state) => ({
lines: state.noteDetails.markdownContent.lines,
content: state.noteDetails.markdownContent.plain
}))
const lineOffset = useApplicationState((state) => state.noteDetails.startOfContentLineOffset)
const markdownContent = useApplicationState((state) => {
const noteDetails = state.noteDetails
if (!noteDetails) {
return undefined
} else {
return {
lines: noteDetails.markdownContent.lines,
content: noteDetails.markdownContent.plain
}
}
})
const lineOffset = useApplicationState((state) => state.noteDetails?.startOfContentLineOffset)
const trimmedLines = useMemo(() => {
if (!markdownContent) {
return undefined
}
if (markdownContent.content.length > maxLength) {
return markdownContent.content.slice(0, maxLength).split('\n')
} else {
@ -29,6 +39,6 @@ export const useTrimmedNoteMarkdownContentWithoutFrontmatter = (): string[] => {
}, [markdownContent, maxLength])
return useMemo(() => {
return trimmedLines.slice(lineOffset)
return trimmedLines === undefined || lineOffset === undefined ? [] : trimmedLines.slice(lineOffset)
}, [lineOffset, trimmedLines])
}

View file

@ -6,17 +6,17 @@
import type { HistoryEntryWithOrigin } from '../api/history/types'
import type { DarkModeConfig } from './dark-mode/types'
import type { EditorConfig } from './editor/types'
import type { NoteDetails } from './note-details/types/note-details'
import type { RealtimeStatus } from './realtime/types'
import type { RendererStatus } from './renderer-status/types'
import type { OptionalUserState } from './user/types'
import type { OptionalNoteDetails } from './note-details/types/note-details'
export interface ApplicationState {
user: OptionalUserState
history: HistoryEntryWithOrigin[]
editorConfig: EditorConfig
darkMode: DarkModeConfig
noteDetails: NoteDetails
noteDetails: OptionalNoteDetails
rendererStatus: RendererStatus
realtimeStatus: RealtimeStatus
}

View file

@ -73,9 +73,19 @@ export const updateCursorPositions = (selection: CursorSelection): void => {
* Updates the current note's metadata from the server.
*/
export const updateMetadata = async (): Promise<void> => {
const updatedMetadata = await getNoteMetadata(store.getState().noteDetails.id)
const noteDetails = store.getState().noteDetails
if (!noteDetails) {
return
}
const updatedMetadata = await getNoteMetadata(noteDetails.id)
store.dispatch({
type: NoteDetailsActionType.UPDATE_METADATA,
updatedMetadata
} as UpdateMetadataAction)
}
export const unloadNote = (): void => {
store.dispatch({
type: NoteDetailsActionType.UNLOAD_NOTE
})
}

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { buildStateFromUpdatedMarkdownContent } from './build-state-from-updated-markdown-content'
import { initialState } from './initial-state'
import { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update'
import { buildStateFromMetadataUpdate } from './reducers/build-state-from-metadata-update'
import { buildStateFromServerPermissions } from './reducers/build-state-from-server-permissions'
@ -12,13 +11,19 @@ import { buildStateFromServerDto } from './reducers/build-state-from-set-note-da
import { buildStateFromUpdateCursorPosition } from './reducers/build-state-from-update-cursor-position'
import type { NoteDetailsActions } from './types'
import { NoteDetailsActionType } from './types'
import type { NoteDetails } from './types/note-details'
import type { OptionalNoteDetails } from './types/note-details'
import type { Reducer } from 'redux'
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
state: NoteDetails = initialState,
export const NoteDetailsReducer: Reducer<OptionalNoteDetails, NoteDetailsActions> = (
state: OptionalNoteDetails = null,
action: NoteDetailsActions
) => {
if (action.type === NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER) {
return buildStateFromServerDto(action.noteFromServer)
}
if (state === null) {
return null
}
switch (action.type) {
case NoteDetailsActionType.UPDATE_CURSOR_POSITION:
return buildStateFromUpdateCursorPosition(state, action.selection)
@ -28,10 +33,10 @@ export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
return buildStateFromServerPermissions(state, action.notePermissionsFromServer)
case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING:
return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
return buildStateFromServerDto(action.noteFromServer)
case NoteDetailsActionType.UPDATE_METADATA:
return buildStateFromMetadataUpdate(state, action.updatedMetadata)
case NoteDetailsActionType.UNLOAD_NOTE:
return null
default:
return state
}

View file

@ -14,7 +14,8 @@ export enum NoteDetailsActionType {
SET_NOTE_PERMISSIONS_FROM_SERVER = 'note-details/data/permissions/set',
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
UPDATE_CURSOR_POSITION = 'note-details/updateCursorPosition',
UPDATE_METADATA = 'note-details/update-metadata'
UPDATE_METADATA = 'note-details/update-metadata',
UNLOAD_NOTE = 'note-details/unload-note'
}
export type NoteDetailsActions =
@ -24,6 +25,7 @@ export type NoteDetailsActions =
| UpdateNoteTitleByFirstHeadingAction
| UpdateCursorPositionAction
| UpdateMetadataAction
| UnloadNoteAction
/**
* Action for updating the document content of the currently loaded note.
@ -69,3 +71,7 @@ export interface UpdateMetadataAction extends Action<NoteDetailsActionType> {
type: NoteDetailsActionType.UPDATE_METADATA
updatedMetadata: NoteMetadata
}
export interface UnloadNoteAction extends Action<NoteDetailsActionType> {
type: NoteDetailsActionType.UNLOAD_NOTE
}

View file

@ -26,3 +26,5 @@ export interface NoteDetails extends Omit<NoteMetadata, UnnecessaryNoteAttribute
frontmatter: NoteFrontmatter
startOfContentLineOffset: number
}
export type OptionalNoteDetails = NoteDetails | null