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') cy.getByCypressId('sidebar.revision.modal').should('be.visible')
}) })
it('can download revisions', () => { 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, id: 1,
createdAt: defaultCreatedAt, createdAt: defaultCreatedAt,
title: 'Features', title: 'Features',
@ -86,7 +86,7 @@ describe('Revision modal', () => {
}) })
const downloadFolder = Cypress.config('downloadsFolder') 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) const filePath = join(downloadFolder, fileName)
cy.getByCypressId('revision.modal.lists').contains(formattedDate).click() 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 { useLoadNoteFromServer } from './hooks/use-load-note-from-server'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React, { useEffect, useMemo } from 'react' import React, { useEffect, useMemo } from 'react'
import { unloadNote } from '../../../redux/note-details/methods'
const logger = new Logger('NoteLoadingBoundary') const logger = new Logger('NoteLoadingBoundary')
@ -37,6 +38,13 @@ export const NoteLoadingBoundary: React.FC<PropsWithChildren<NoteIdProps>> = ({
loadNoteFromServer() loadNoteFromServer()
}, [loadNoteFromServer]) }, [loadNoteFromServer])
useEffect(
() => () => {
unloadNote()
},
[]
)
const errorComponent = useMemo(() => { const errorComponent = useMemo(() => {
if (error === undefined) { if (error === undefined) {
return null return null

View file

@ -15,17 +15,18 @@ import { useMemo } from 'react'
*/ */
export const useSendAdditionalConfigurationToRenderer = (rendererReady: boolean): void => { export const useSendAdditionalConfigurationToRenderer = (rendererReady: boolean): void => {
const darkModePreference = useApplicationState((state) => state.darkMode.darkModePreference) const darkModePreference = useApplicationState((state) => state.darkMode.darkModePreference)
const newlinesAreBreaks = useApplicationState((state) => state.noteDetails.frontmatter.newlinesAreBreaks) const newlinesAreBreaks = useApplicationState((state) => state.noteDetails?.frontmatter.newlinesAreBreaks)
useSendToRenderer( useSendToRenderer(
useMemo( useMemo(() => {
() => ({ return newlinesAreBreaks === undefined
type: CommunicationMessageType.SET_ADDITIONAL_CONFIGURATION, ? undefined
darkModePreference: darkModePreference, : {
newLinesAreBreaks: newlinesAreBreaks type: CommunicationMessageType.SET_ADDITIONAL_CONFIGURATION,
}), darkModePreference: darkModePreference,
[darkModePreference, newlinesAreBreaks] newLinesAreBreaks: newlinesAreBreaks
), }
}, [darkModePreference, newlinesAreBreaks]),
rendererReady 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. // TODO Check permissions ("writability") of note and show edit link depending on that.
const linkTitle = useTranslatedText('views.readOnly.editNote') const linkTitle = useTranslatedText('views.readOnly.editNote')
if (noteDetails === null) {
return null
}
return ( return (
<div className={`d-flex flex-row my-3 ${styles['document-infobar']}`}> <div className={`d-flex flex-row my-3 ${styles['document-infobar']}`}>
<div className={'col-md'}>&nbsp;</div> <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 type { CursorSelection } from '../../tool-bar/formatters/types/cursor-selection'
import { useHandleUpload } from '../use-handle-upload' import { useHandleUpload } from '../use-handle-upload'
import { findRegexMatchInText } from './find-regex-match-in-text' import { findRegexMatchInText } from './find-regex-match-in-text'
import { Optional } from '@mrdrogdrog/optional'
import { useCallback } from 'react' import { useCallback } from 'react'
const log = new Logger('useOnImageUpload') const log = new Logger('useOnImageUpload')
@ -43,12 +42,7 @@ export const useOnImageUploadFromRenderer = (): void => {
.then((result) => result.blob()) .then((result) => result.blob())
.then((blob) => { .then((blob) => {
const file = new File([blob], fileName, { type: blob.type }) const file = new File([blob], fileName, { type: blob.type })
const { cursorSelection, alt, title } = Optional.ofNullable(lineIndex) const { cursorSelection, alt, title } = findPlaceholderInMarkdownContent(lineIndex, placeholderIndexInLine)
.flatMap((actualLineIndex) => {
const lineOffset = getGlobalState().noteDetails.startOfContentLineOffset
return findPlaceholderInMarkdownContent(actualLineIndex + lineOffset, placeholderIndexInLine)
})
.orElse({} as ExtractResult)
handleUpload(codeMirrorReference, file, cursorSelection, alt, title) handleUpload(codeMirrorReference, file, cursorSelection, alt, title)
}) })
.catch((error) => log.error(error)) .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. * @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 * @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 const noteDetails = getGlobalState().noteDetails
if (!noteDetails) {
return {}
}
const currentMarkdownContentLines = noteDetails.markdownContent.lines const currentMarkdownContentLines = noteDetails.markdownContent.lines
return Optional.ofNullable(noteDetails.markdownContent.lineStartIndexes[lineIndex]).map((startIndexOfLine) => const actualLineIndex = noteDetails.startOfContentLineOffset + lineIndex
findImagePlaceholderInLine(currentMarkdownContentLines[lineIndex], startIndexOfLine, replacementIndexInLine) 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 }) : t('editor.upload.uploadFile.withoutDescription', { fileName: file.name })
const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})` const uploadPlaceholder = `![${uploadFileInfo}](upload-${randomId}${additionalUrlText ?? ''})`
const noteId = getGlobalState().noteDetails.id const noteId = getGlobalState().noteDetails?.id
if (noteId === undefined) {
return
}
changeContent(({ currentSelection }) => { changeContent(({ currentSelection }) => {
return replaceSelection(cursorSelection ?? currentSelection, uploadPlaceholder, false) 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. * Returns the line+character based position of the to-cursor, if available.
*/ */
export const useLineBasedToPosition = (): LineBasedPosition | undefined => { export const useLineBasedToPosition = (): LineBasedPosition | undefined => {
const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes) const lineStartIndexes = useApplicationState((state) => state.noteDetails?.markdownContent.lineStartIndexes ?? [])
const selection = useApplicationState((state) => state.noteDetails.selection) const selectionTo = useApplicationState((state) => state.noteDetails?.selection.to)
return useMemo(() => { return useMemo(() => {
const to = selection.to if (selectionTo === undefined) {
if (to === undefined) {
return undefined return undefined
} }
return calculateLineBasedPosition(to, lineStartIndexes) return calculateLineBasedPosition(selectionTo, lineStartIndexes)
}, [selection.to, lineStartIndexes]) }, [selectionTo, lineStartIndexes])
} }
/** /**
* Returns the line+character based position of the from-cursor. * Returns the line+character based position of the from-cursor.
*/ */
export const useLineBasedFromPosition = (): LineBasedPosition => { export const useLineBasedFromPosition = (): LineBasedPosition => {
const lineStartIndexes = useApplicationState((state) => state.noteDetails.markdownContent.lineStartIndexes) const lineStartIndexes = useApplicationState((state) => state.noteDetails?.markdownContent.lineStartIndexes ?? [])
const selection = useApplicationState((state) => state.noteDetails.selection) const selection = useApplicationState((state) => state.noteDetails?.selection ?? { from: 0 })
return useMemo(() => { return useMemo(() => {
return calculateLineBasedPosition(selection.from, lineStartIndexes) return calculateLineBasedPosition(selection.from, lineStartIndexes)

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import { Trans, useTranslation } from 'react-i18next'
export const NumberOfLinesInDocumentInfo: React.FC = () => { export const NumberOfLinesInDocumentInfo: React.FC = () => {
useTranslation() 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]) const translationOptions = useMemo(() => ({ lines: linesInDocument }), [linesInDocument])
return ( return (

View file

@ -16,7 +16,7 @@ export const RemainingCharactersInfo: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const maxDocumentLength = useFrontendConfig().maxDocumentLength 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 remainingCharacters = useMemo(() => maxDocumentLength - contentLength, [contentLength, maxDocumentLength])
const remainingCharactersClass = useMemo(() => { const remainingCharactersClass = useMemo(() => {

View file

@ -14,10 +14,10 @@ import { Trans, useTranslation } from 'react-i18next'
export const SelectedCharacters: React.FC = () => { export const SelectedCharacters: React.FC = () => {
useTranslation() useTranslation()
const selection = useApplicationState((state) => state.noteDetails.selection) const selection = useApplicationState((state) => state.noteDetails?.selection)
const count = useMemo( const count = useMemo(
() => (selection.to === undefined ? undefined : selection.to - selection.from), () => (selection === undefined || selection.to === undefined ? undefined : selection.to - selection.from),
[selection.from, selection.to] [selection]
) )
const countTranslationOptions = useMemo(() => ({ count }), [count]) 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. * The entry is updated when the title or tags of the note change.
*/ */
export const useUpdateLocalHistoryEntry = (): void => { 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 userExists = useApplicationState((state) => !!state.user)
const currentNoteTitle = useApplicationState((state) => state.noteDetails.title) const currentNoteTitle = useApplicationState((state) => state.noteDetails?.title ?? '')
const currentNoteTags = useApplicationState((state) => state.noteDetails.frontmatter.tags) const currentNoteTags = useApplicationState((state) => state.noteDetails?.frontmatter.tags ?? [])
const lastNoteTitle = useRef('') const lastNoteTitle = useRef('')
const lastNoteTags = useRef<string[]>([]) const lastNoteTags = useRef<string[]>([])
useEffect(() => { useEffect(() => {
if (userExists) { if (userExists || id === undefined) {
return return
} }
if (currentNoteTitle === lastNoteTitle.current && equal(currentNoteTags, lastNoteTags.current)) { if (currentNoteTitle === lastNoteTitle.current && equal(currentNoteTags, lastNoteTags.current)) {

View file

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

View file

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

View file

@ -28,10 +28,14 @@ export type RendererPaneProps = Omit<
*/ */
export const RendererPane: React.FC<RendererPaneProps> = ({ scrollState, onScroll, ...props }) => { export const RendererPane: React.FC<RendererPaneProps> = ({ scrollState, onScroll, ...props }) => {
const trimmedContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter() 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 adjustedOnScroll = useOnScrollWithLineOffset(onScroll)
const adjustedScrollState = useScrollStateWithoutLineOffset(scrollState) const adjustedScrollState = useScrollStateWithoutLineOffset(scrollState)
if (!noteType) {
return null
}
return ( return (
<RendererIframe <RendererIframe
{...props} {...props}

View file

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

View file

@ -12,12 +12,14 @@ import React, { Fragment, useMemo } from 'react'
* Renders the list of aliases. * Renders the list of aliases.
*/ */
export const AliasesList: React.FC = () => { export const AliasesList: React.FC = () => {
const aliases = useApplicationState((state: ApplicationState) => state.noteDetails.aliases) const aliases = useApplicationState((state: ApplicationState) => state.noteDetails?.aliases)
const aliasesDom = useMemo(() => { const aliasesDom = useMemo(() => {
return aliases return aliases === undefined
.sort((a, b) => a.name.localeCompare(b.name)) ? null
.map((alias) => <AliasesListEntry alias={alias} key={alias.name} />) : aliases
.sort((a, b) => a.name.localeCompare(b.name))
.map((alias) => <AliasesListEntry alias={alias} key={alias.name} />)
}, [aliases]) }, [aliases])
return <Fragment>{aliasesDom}</Fragment> 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 }) => { export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarEntryProps>> = ({ hide, className }) => {
useTranslation() useTranslation()
const router = useRouter() const router = useRouter()
const noteId = useApplicationState((state) => state.noteDetails.id) const noteId = useApplicationState((state) => state.noteDetails?.id)
const [modalVisibility, showModal, closeModal] = useBooleanState() const [modalVisibility, showModal, closeModal] = useBooleanState()
const { showErrorNotification } = useUiNotifications() const { showErrorNotification } = useUiNotifications()
const deleteNoteAndCloseDialog = useCallback(() => { const deleteNoteAndCloseDialog = useCallback(() => {
if (noteId === undefined) {
return
}
deleteNote(noteId) deleteNote(noteId)
.then(() => router.push('/history')) .then(() => router.push('/history'))
.catch(showErrorNotification('landing.history.error.deleteNote.text')) .catch(showErrorNotification('landing.history.error.deleteNote.text'))

View file

@ -20,7 +20,11 @@ export const ExportMarkdownSidebarEntry: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const markdownContent = useNoteMarkdownContent() const markdownContent = useNoteMarkdownContent()
const onClick = useCallback(() => { 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') download(markdownContent, `${sanitized !== '' ? sanitized : t('editor.untitledNote')}.md`, 'text/markdown')
}, [markdownContent, t]) }, [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. * Renders an info line about the number of contributors for the note.
*/ */
export const NoteInfoLineContributors: React.FC = () => { export const NoteInfoLineContributors: React.FC = () => {
const contributors = useApplicationState((state) => state.noteDetails.editedBy.length) const contributors = useApplicationState((state) => state.noteDetails?.editedBy.length ?? 0)
return ( return (
<SidebarMenuInfoEntry titleI18nKey={'editor.noteInfo.contributors'} icon={IconPeople}> <SidebarMenuInfoEntry titleI18nKey={'editor.noteInfo.contributors'} icon={IconPeople}>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,9 +26,13 @@ export const PermissionOwnerInfo: React.FC<PermissionOwnerInfoProps & Permission
onEditOwner, onEditOwner,
disabled 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') const buttonTitle = useTranslatedText('editor.modal.permissions.ownerChange.button')
if (!noteOwner) {
return null
}
return ( return (
<Fragment> <Fragment>
<UserAvatarForUsername username={noteOwner} /> <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. * @param disabled If the user is not the owner, functionality is disabled.
*/ */
export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ 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 [changeOwner, setChangeOwner] = useState(false)
const { showErrorNotification } = useUiNotifications() const { showErrorNotification } = useUiNotifications()
@ -29,6 +29,9 @@ export const PermissionSectionOwner: React.FC<PermissionDisabledProps> = ({ disa
const onOwnerChange = useCallback( const onOwnerChange = useCallback(
(newOwner: string) => { (newOwner: string) => {
if (!noteId) {
return
}
setNoteOwner(noteId, newOwner) setNoteOwner(noteId, newOwner)
.then((updatedPermissions) => { .then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions) setNotePermissionsFromServer(updatedPermissions)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,13 +24,16 @@ export interface LinkFieldProps {
*/ */
export const NoteUrlField: React.FC<LinkFieldProps> = ({ type }) => { export const NoteUrlField: React.FC<LinkFieldProps> = ({ type }) => {
const baseUrl = useBaseUrl() const baseUrl = useBaseUrl()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) const noteId = useApplicationState((state) => state.noteDetails?.id)
const url = useMemo(() => { const url = useMemo(() => {
if (noteId === undefined) {
return undefined
}
const url = new URL(baseUrl) const url = new URL(baseUrl)
url.pathname += `${type}/${noteIdentifier}` url.pathname += `${type}/${noteId}`
return url.toString() 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 }) => { export const ShareModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation() useTranslation()
const noteFrontmatter = useApplicationState((state) => state.noteDetails.frontmatter) const noteFrontmatter = useApplicationState((state) => state.noteDetails?.frontmatter)
if (!noteFrontmatter) {
return null
}
return ( return (
<CommonModal show={show} onHide={onHide} showCloseButton={true} titleI18nKey={'editor.modal.shareLink.title'}> <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() const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
useTranslation() useTranslation()
const slideOptions = useApplicationState((state) => state.noteDetails.frontmatter.slideOptions) const slideOptions = useApplicationState((state) => state.noteDetails?.frontmatter.slideOptions)
const rendererReady = useApplicationState((state) => state.rendererStatus.rendererReady) const rendererReady = useApplicationState((state) => state.rendererStatus.rendererReady)
useSendToRenderer( useSendToRenderer(
useMemo( useMemo(() => {
() => ({ return !slideOptions
type: CommunicationMessageType.SET_SLIDE_OPTIONS, ? undefined
slideOptions : {
}), type: CommunicationMessageType.SET_SLIDE_OPTIONS,
[slideOptions] slideOptions
), }
}, [slideOptions]),
rendererReady rendererReady
) )

View file

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

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from './use-application-state' import { useApplicationState } from './use-application-state'
import type { NotePermissions } from '@hedgedoc/commons'
import { userIsOwner } from '@hedgedoc/commons' import { userIsOwner } from '@hedgedoc/commons'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -15,7 +14,7 @@ import { useMemo } from 'react'
*/ */
export const useIsOwner = (): boolean => { export const useIsOwner = (): boolean => {
const me: string | undefined = useApplicationState((state) => state.user?.username) 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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from './use-application-state' import { useApplicationState } from './use-application-state'
import type { NotePermissions } from '@hedgedoc/commons'
import { userCanEdit } from '@hedgedoc/commons' import { userCanEdit } from '@hedgedoc/commons'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -15,7 +14,7 @@ import { useMemo } from 'react'
*/ */
export const useMayEdit = (): boolean => { export const useMayEdit = (): boolean => {
const me: string | undefined = useApplicationState((state) => state.user?.username) 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 * @return The markdown content of the note
*/ */
export const useNoteMarkdownContent = (): string => { 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 => { export const useNoteTitle = (): string => {
const untitledNote = useTranslatedText('editor.untitledNote') 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[] => { export const useTrimmedNoteMarkdownContentWithoutFrontmatter = (): string[] => {
const maxLength = useFrontendConfig().maxDocumentLength const maxLength = useFrontendConfig().maxDocumentLength
const markdownContent = useApplicationState((state) => ({ const markdownContent = useApplicationState((state) => {
lines: state.noteDetails.markdownContent.lines, const noteDetails = state.noteDetails
content: state.noteDetails.markdownContent.plain if (!noteDetails) {
})) return undefined
const lineOffset = useApplicationState((state) => state.noteDetails.startOfContentLineOffset) } else {
return {
lines: noteDetails.markdownContent.lines,
content: noteDetails.markdownContent.plain
}
}
})
const lineOffset = useApplicationState((state) => state.noteDetails?.startOfContentLineOffset)
const trimmedLines = useMemo(() => { const trimmedLines = useMemo(() => {
if (!markdownContent) {
return undefined
}
if (markdownContent.content.length > maxLength) { if (markdownContent.content.length > maxLength) {
return markdownContent.content.slice(0, maxLength).split('\n') return markdownContent.content.slice(0, maxLength).split('\n')
} else { } else {
@ -29,6 +39,6 @@ export const useTrimmedNoteMarkdownContentWithoutFrontmatter = (): string[] => {
}, [markdownContent, maxLength]) }, [markdownContent, maxLength])
return useMemo(() => { return useMemo(() => {
return trimmedLines.slice(lineOffset) return trimmedLines === undefined || lineOffset === undefined ? [] : trimmedLines.slice(lineOffset)
}, [lineOffset, trimmedLines]) }, [lineOffset, trimmedLines])
} }

View file

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

View file

@ -73,9 +73,19 @@ export const updateCursorPositions = (selection: CursorSelection): void => {
* Updates the current note's metadata from the server. * Updates the current note's metadata from the server.
*/ */
export const updateMetadata = async (): Promise<void> => { 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({ store.dispatch({
type: NoteDetailsActionType.UPDATE_METADATA, type: NoteDetailsActionType.UPDATE_METADATA,
updatedMetadata updatedMetadata
} as UpdateMetadataAction) } 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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { buildStateFromUpdatedMarkdownContent } from './build-state-from-updated-markdown-content' 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 { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update'
import { buildStateFromMetadataUpdate } from './reducers/build-state-from-metadata-update' import { buildStateFromMetadataUpdate } from './reducers/build-state-from-metadata-update'
import { buildStateFromServerPermissions } from './reducers/build-state-from-server-permissions' 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 { buildStateFromUpdateCursorPosition } from './reducers/build-state-from-update-cursor-position'
import type { NoteDetailsActions } from './types' import type { NoteDetailsActions } from './types'
import { NoteDetailsActionType } 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' import type { Reducer } from 'redux'
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = ( export const NoteDetailsReducer: Reducer<OptionalNoteDetails, NoteDetailsActions> = (
state: NoteDetails = initialState, state: OptionalNoteDetails = null,
action: NoteDetailsActions action: NoteDetailsActions
) => { ) => {
if (action.type === NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER) {
return buildStateFromServerDto(action.noteFromServer)
}
if (state === null) {
return null
}
switch (action.type) { switch (action.type) {
case NoteDetailsActionType.UPDATE_CURSOR_POSITION: case NoteDetailsActionType.UPDATE_CURSOR_POSITION:
return buildStateFromUpdateCursorPosition(state, action.selection) return buildStateFromUpdateCursorPosition(state, action.selection)
@ -28,10 +33,10 @@ export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
return buildStateFromServerPermissions(state, action.notePermissionsFromServer) return buildStateFromServerPermissions(state, action.notePermissionsFromServer)
case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING: case NoteDetailsActionType.UPDATE_NOTE_TITLE_BY_FIRST_HEADING:
return buildStateFromFirstHeadingUpdate(state, action.firstHeading) return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
return buildStateFromServerDto(action.noteFromServer)
case NoteDetailsActionType.UPDATE_METADATA: case NoteDetailsActionType.UPDATE_METADATA:
return buildStateFromMetadataUpdate(state, action.updatedMetadata) return buildStateFromMetadataUpdate(state, action.updatedMetadata)
case NoteDetailsActionType.UNLOAD_NOTE:
return null
default: default:
return state return state
} }

View file

@ -14,7 +14,8 @@ export enum NoteDetailsActionType {
SET_NOTE_PERMISSIONS_FROM_SERVER = 'note-details/data/permissions/set', 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_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
UPDATE_CURSOR_POSITION = 'note-details/updateCursorPosition', 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 = export type NoteDetailsActions =
@ -24,6 +25,7 @@ export type NoteDetailsActions =
| UpdateNoteTitleByFirstHeadingAction | UpdateNoteTitleByFirstHeadingAction
| UpdateCursorPositionAction | UpdateCursorPositionAction
| UpdateMetadataAction | UpdateMetadataAction
| UnloadNoteAction
/** /**
* Action for updating the document content of the currently loaded note. * 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 type: NoteDetailsActionType.UPDATE_METADATA
updatedMetadata: NoteMetadata 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 frontmatter: NoteFrontmatter
startOfContentLineOffset: number startOfContentLineOffset: number
} }
export type OptionalNoteDetails = NoteDetails | null