feat(motd): read motd in RSC and provide via context

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-10-13 21:22:21 +02:00 committed by Erik Michelson
parent 83c7f81a76
commit a0bc8e98d0
11 changed files with 190 additions and 125 deletions

View file

@ -5,29 +5,18 @@
*/ */
const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified' const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
const MOCK_LAST_MODIFIED = 'mockETag' const motdMockHtml = 'This is the test motd text'
const motdMockContent = 'This is the **mock** Motd call'
const motdMockHtml = 'This is the <strong>mock</strong> Motd call'
describe('Motd', () => { describe('Motd', () => {
it("shows, dismisses and won't show again a motd modal", () => { it("shows, dismisses and won't show again a motd modal", () => {
localStorage.removeItem(MOTD_LOCAL_STORAGE_KEY) localStorage.removeItem(MOTD_LOCAL_STORAGE_KEY)
cy.intercept('GET', '/public/motd.md', {
statusCode: 200,
headers: { 'Last-Modified': MOCK_LAST_MODIFIED },
body: motdMockContent
})
cy.intercept('HEAD', '/public/motd.md', {
statusCode: 200,
headers: { 'Last-Modified': MOCK_LAST_MODIFIED }
})
cy.visitHistory() cy.visitHistory()
cy.getSimpleRendererBody().should('contain.html', motdMockHtml) cy.getSimpleRendererBody().should('contain.text', motdMockHtml)
cy.getByCypressId('motd-dismiss') cy.getByCypressId('motd-dismiss')
.click() .click()
.then(() => { .then(() => {
expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).to.equal(MOCK_LAST_MODIFIED) expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).not.to.be.eq(null)
}) })
cy.getByCypressId('motd-modal').should('not.exist') cy.getByCypressId('motd-modal').should('not.exist')
cy.reload() cy.reload()

View file

@ -506,7 +506,8 @@
}, },
"instance": { "instance": {
"header": "About this instance", "header": "About this instance",
"versionInfo": "Running version" "versionInfo": "Running version",
"motdModal": "Show instance announcement"
}, },
"legal": { "legal": {
"header": "Legal", "header": "Legal",
@ -596,7 +597,7 @@
} }
}, },
"motd": { "motd": {
"title": "Information" "title": "Announcement"
}, },
"errors": { "errors": {
"notFound": { "notFound": {

View file

@ -7,7 +7,6 @@ import '../../../global-styles/index.scss'
import { ApplicationLoader } from '../../components/application-loader/application-loader' import { ApplicationLoader } from '../../components/application-loader/application-loader'
import { BaseUrlContextProvider } from '../../components/common/base-url/base-url-context-provider' import { BaseUrlContextProvider } from '../../components/common/base-url/base-url-context-provider'
import { FrontendConfigContextProvider } from '../../components/common/frontend-config-context/frontend-config-context-provider' import { FrontendConfigContextProvider } from '../../components/common/frontend-config-context/frontend-config-context-provider'
import { MotdModal } from '../../components/global-dialogs/motd-modal/motd-modal'
import { DarkMode } from '../../components/layout/dark-mode/dark-mode' import { DarkMode } from '../../components/layout/dark-mode/dark-mode'
import { ExpectedOriginBoundary } from '../../components/layout/expected-origin-boundary' import { ExpectedOriginBoundary } from '../../components/layout/expected-origin-boundary'
import { UiNotificationBoundary } from '../../components/notifications/ui-notification-boundary' import { UiNotificationBoundary } from '../../components/notifications/ui-notification-boundary'
@ -18,6 +17,9 @@ import type { Metadata } from 'next'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React from 'react' import React from 'react'
import { getConfig } from '../../api/config' import { getConfig } from '../../api/config'
import { MotdProvider } from '../../components/motd/motd-context'
import { fetchMotd } from '../../components/global-dialogs/motd-modal/fetch-motd'
import { CachedMotdModal } from '../../components/global-dialogs/motd-modal/cached-motd-modal'
configureLuxon() configureLuxon()
@ -28,6 +30,7 @@ interface RootLayoutProps extends PropsWithChildren {
export default async function RootLayout({ children, appBar }: RootLayoutProps) { export default async function RootLayout({ children, appBar }: RootLayoutProps) {
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls() const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls()
const frontendConfig = await getConfig(baseUrls.editor) const frontendConfig = await getConfig(baseUrls.editor)
const motd = await fetchMotd(baseUrls.internalApiUrl ?? baseUrls.editor)
return ( return (
<html lang='en'> <html lang='en'>
@ -37,11 +40,12 @@ export default async function RootLayout({ children, appBar }: RootLayoutProps)
<body> <body>
<ExpectedOriginBoundary expectedOrigin={baseUrls.editor}> <ExpectedOriginBoundary expectedOrigin={baseUrls.editor}>
<BaseUrlContextProvider baseUrls={baseUrls}> <BaseUrlContextProvider baseUrls={baseUrls}>
<MotdProvider motd={motd}>
<FrontendConfigContextProvider config={frontendConfig}> <FrontendConfigContextProvider config={frontendConfig}>
<StoreProvider> <StoreProvider>
<ApplicationLoader> <ApplicationLoader>
<DarkMode /> <DarkMode />
<MotdModal /> <CachedMotdModal />
<UiNotificationBoundary> <UiNotificationBoundary>
<div className={'d-flex flex-column vh-100'}> <div className={'d-flex flex-column vh-100'}>
{appBar} {appBar}
@ -51,6 +55,7 @@ export default async function RootLayout({ children, appBar }: RootLayoutProps)
</ApplicationLoader> </ApplicationLoader>
</StoreProvider> </StoreProvider>
</FrontendConfigContextProvider> </FrontendConfigContextProvider>
</MotdProvider>
</BaseUrlContextProvider> </BaseUrlContextProvider>
</ExpectedOriginBoundary> </ExpectedOriginBoundary>
</body> </body>

View file

@ -0,0 +1,44 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo, useState } from 'react'
import { useMotdContextValue } from '../../motd/motd-context'
import { useLocalStorage } from 'react-use'
import { MOTD_LOCAL_STORAGE_KEY } from './fetch-motd'
import { MotdModal } from './motd-modal'
import { testId } from '../../../utils/test-id'
/**
* Reads the motd from the context and shows it in a modal.
* If the modal gets dismissed by the user then the "last modified" identifier will be written into the local storage
* to prevent that the motd will be shown again until it gets changed.
*/
export const CachedMotdModal: React.FC = () => {
const contextValue = useMotdContextValue()
const [cachedLastModified, saveLocalStorage] = useLocalStorage<string>(MOTD_LOCAL_STORAGE_KEY, undefined)
const [dismissed, setDismissed] = useState(false)
const show = useMemo(() => {
const lastModified = contextValue?.lastModified
return cachedLastModified !== lastModified && lastModified !== undefined && !dismissed
}, [cachedLastModified, contextValue?.lastModified, dismissed])
const doDismiss = useCallback(() => {
const lastModified = contextValue?.lastModified
if (lastModified) {
saveLocalStorage(lastModified)
}
setDismissed(true)
}, [contextValue, saveLocalStorage])
if (contextValue?.lastModified === undefined && process.env.NODE_ENV === 'test') {
return <span {...testId('loaded not visible')} />
}
return <MotdModal show={show} onDismiss={doDismiss}></MotdModal>
}

View file

@ -7,7 +7,8 @@ import { fetchMotd } from './fetch-motd'
import { Mock } from 'ts-mockery' import { Mock } from 'ts-mockery'
describe('fetch motd', () => { describe('fetch motd', () => {
const motdUrl = '/public/motd.md' const baseUrl = 'https://example.org/'
const motdUrl = `${baseUrl}public/motd.md`
beforeEach(() => { beforeEach(() => {
window.localStorage.clear() window.localStorage.clear()
@ -47,7 +48,7 @@ describe('fetch motd', () => {
jest.spyOn(global, 'fetch').mockImplementation(() => jest.spyOn(global, 'fetch').mockImplementation(() =>
Promise.resolve( Promise.resolve(
Mock.of<Response>({ Mock.of<Response>({
status: 500 status: 404
}) })
) )
) )
@ -56,7 +57,7 @@ describe('fetch motd', () => {
describe('date detection', () => { describe('date detection', () => {
it('will return the last-modified value if available', async () => { it('will return the last-modified value if available', async () => {
mockFetch('mocked motd', 'yesterday-modified', null) mockFetch('mocked motd', 'yesterday-modified', null)
const result = fetchMotd() const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({ await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd', motdText: 'mocked motd',
lastModified: 'yesterday-modified' lastModified: 'yesterday-modified'
@ -64,7 +65,7 @@ describe('fetch motd', () => {
}) })
it('will return the etag if last-modified is not returned', async () => { it('will return the etag if last-modified is not returned', async () => {
mockFetch('mocked motd', null, 'yesterday-etag') mockFetch('mocked motd', null, 'yesterday-etag')
const result = fetchMotd() const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({ await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd', motdText: 'mocked motd',
lastModified: 'yesterday-etag' lastModified: 'yesterday-etag'
@ -72,7 +73,7 @@ describe('fetch motd', () => {
}) })
it('will prefer the last-modified header over the etag', async () => { it('will prefer the last-modified header over the etag', async () => {
mockFetch('mocked motd', 'yesterday-last', 'yesterday-etag') mockFetch('mocked motd', 'yesterday-last', 'yesterday-etag')
const result = fetchMotd() const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({ await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd', motdText: 'mocked motd',
lastModified: 'yesterday-last' lastModified: 'yesterday-last'
@ -80,34 +81,24 @@ describe('fetch motd', () => {
}) })
it('will return an empty value if neither the last-modified value nor the etag is returned', async () => { it('will return an empty value if neither the last-modified value nor the etag is returned', async () => {
mockFetch('mocked motd', null, null) mockFetch('mocked motd', null, null)
const result = fetchMotd() const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({ await expect(result).resolves.toBe(undefined)
motdText: 'mocked motd',
lastModified: null
})
}) })
}) })
it('can fetch a motd if no last modified value has been memorized', async () => { it('can fetch a motd if no last modified value has been memorized', async () => {
mockFetch('mocked motd', 'yesterday') mockFetch('mocked motd', 'yesterday')
const result = fetchMotd() const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({ await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd', motdText: 'mocked motd',
lastModified: 'yesterday' lastModified: 'yesterday'
}) })
}) })
it("can detect that the motd hasn't been updated", async () => {
mockFetch('mocked motd', 'yesterday')
window.localStorage.setItem('motd.lastModified', 'yesterday')
const result = fetchMotd()
await expect(result).resolves.toStrictEqual(undefined)
})
it('can detect that the motd has been updated', async () => { it('can detect that the motd has been updated', async () => {
mockFetch('mocked motd', 'yesterday') mockFetch('mocked motd', 'yesterday')
window.localStorage.setItem('motd.lastModified', 'the day before yesterday') window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
const result = fetchMotd() const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({ await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd', motdText: 'mocked motd',
lastModified: 'yesterday' lastModified: 'yesterday'
@ -116,14 +107,14 @@ describe('fetch motd', () => {
it("won't fetch a motd if no file was found", async () => { it("won't fetch a motd if no file was found", async () => {
mockFileNotFoundFetch() mockFileNotFoundFetch()
const result = fetchMotd() const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual(undefined) await expect(result).resolves.toStrictEqual(undefined)
}) })
it("won't fetch a motd update if no file was found", async () => { it("won't fetch a motd update if no file was found", async () => {
mockFileNotFoundFetch() mockFileNotFoundFetch()
window.localStorage.setItem('motd.lastModified', 'the day before yesterday') window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
const result = fetchMotd() const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual(undefined) await expect(result).resolves.toStrictEqual(undefined)
}) })
}) })

View file

@ -4,14 +4,12 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { defaultConfig } from '../../../api/common/default-config' import { defaultConfig } from '../../../api/common/default-config'
import { Logger } from '../../../utils/logger'
export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified' export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
const log = new Logger('Motd')
export interface MotdApiResponse { export interface MotdApiResponse {
motdText: string motdText: string
lastModified: string | null lastModified: string
} }
/** /**
@ -21,36 +19,23 @@ export interface MotdApiResponse {
* will be compared to the saved value from the browser's local storage. * will be compared to the saved value from the browser's local storage.
* @return A promise that gets resolved if the motd was fetched successfully. * @return A promise that gets resolved if the motd was fetched successfully.
*/ */
export const fetchMotd = async (): Promise<MotdApiResponse | undefined> => { export const fetchMotd = async (baseUrl: string): Promise<MotdApiResponse | undefined> => {
const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY) const motdUrl = `${baseUrl}public/motd.md`
const motdUrl = `/public/motd.md`
if (cachedLastModified) {
const response = await fetch(motdUrl, {
...defaultConfig,
method: 'HEAD'
})
if (response.status !== 200) {
return undefined
}
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
if (lastModified === cachedLastModified) {
return undefined
}
}
const response = await fetch(motdUrl, { const response = await fetch(motdUrl, {
...defaultConfig ...defaultConfig
}) })
if (response.status !== 200) { if (response.status !== 200) {
return undefined return
} }
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag') const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
if (!lastModified) { if (lastModified === null) {
log.warn("'Last-Modified' or 'Etag' not found for motd.md!") return
} }
return { motdText: await response.text(), lastModified } return {
lastModified,
motdText: await response.text()
}
} }

View file

@ -9,13 +9,12 @@ import { testId } from '../../../utils/test-id'
import type { CommonModalProps } from '../../common/modals/common-modal' import type { CommonModalProps } from '../../common/modals/common-modal'
import * as CommonModalModule from '../../common/modals/common-modal' import * as CommonModalModule from '../../common/modals/common-modal'
import * as RendererIframeModule from '../../common/renderer-iframe/renderer-iframe' import * as RendererIframeModule from '../../common/renderer-iframe/renderer-iframe'
import * as fetchMotdModule from './fetch-motd'
import { MotdModal } from './motd-modal'
import { act, render, screen } from '@testing-library/react' import { act, render, screen } from '@testing-library/react'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React from 'react' import React from 'react'
import { CachedMotdModal } from './cached-motd-modal'
import { MotdProvider } from '../../motd/motd-context'
jest.mock('./fetch-motd')
jest.mock('../../common/modals/common-modal') jest.mock('../../common/modals/common-modal')
jest.mock('../../common/renderer-iframe/renderer-iframe') jest.mock('../../common/renderer-iframe/renderer-iframe')
jest.mock('../../../hooks/common/use-base-url') jest.mock('../../../hooks/common/use-base-url')
@ -49,13 +48,15 @@ describe('motd modal', () => {
}) })
it('renders a modal if a motd was fetched and can dismiss it', async () => { it('renders a modal if a motd was fetched and can dismiss it', async () => {
jest.spyOn(fetchMotdModule, 'fetchMotd').mockImplementation(() => { const motd = {
return Promise.resolve({
motdText: 'very important mock text!', motdText: 'very important mock text!',
lastModified: 'yesterday' lastModified: 'yesterday'
}) }
}) const view = render(
const view = render(<MotdModal></MotdModal>) <MotdProvider motd={motd}>
<CachedMotdModal></CachedMotdModal>
</MotdProvider>
)
await screen.findByTestId('motd-renderer') await screen.findByTestId('motd-renderer')
expect(view.container).toMatchSnapshot() expect(view.container).toMatchSnapshot()
@ -67,10 +68,11 @@ describe('motd modal', () => {
}) })
it("doesn't render a modal if no motd has been fetched", async () => { it("doesn't render a modal if no motd has been fetched", async () => {
jest.spyOn(fetchMotdModule, 'fetchMotd').mockImplementation(() => { const view = render(
return Promise.resolve(undefined) <MotdProvider motd={undefined}>
}) <CachedMotdModal></CachedMotdModal>
const view = render(<MotdModal></MotdModal>) </MotdProvider>
)
await screen.findByTestId('loaded not visible') await screen.findByTestId('loaded not visible')
expect(view.container).toMatchSnapshot() expect(view.container).toMatchSnapshot()
}) })

View file

@ -5,55 +5,41 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import { Logger } from '../../../utils/logger'
import { testId } from '../../../utils/test-id' import { testId } from '../../../utils/test-id'
import { CommonModal } from '../../common/modals/common-modal' import { CommonModal } from '../../common/modals/common-modal'
import { RendererIframe } from '../../common/renderer-iframe/renderer-iframe' import { RendererIframe } from '../../common/renderer-iframe/renderer-iframe'
import { EditorToRendererCommunicatorContextProvider } from '../../editor-page/render-context/editor-to-renderer-communicator-context-provider' import { EditorToRendererCommunicatorContextProvider } from '../../editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { RendererType } from '../../render-page/window-post-message-communicator/rendering-message' import { RendererType } from '../../render-page/window-post-message-communicator/rendering-message'
import { fetchMotd, MOTD_LOCAL_STORAGE_KEY } from './fetch-motd' import React, { useMemo } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Modal } from 'react-bootstrap' import { Button, Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { useAsync } from 'react-use' import { useMotdContextValue } from '../../motd/motd-context'
const logger = new Logger('Motd') export interface MotdModalProps {
show: boolean
onDismiss?: () => void
}
/** /**
* Reads the motd from the global application state and shows it in a modal. * Shows the MotD that is provided via context.
* If the modal gets dismissed by the user then the "last modified" identifier will be written into the local storage *
* to prevent that the motd will be shown again until it gets changed. * @param show defines if the modal should be shown
* @param onDismiss callback that is executed if the modal is dismissed
*/ */
export const MotdModal: React.FC = () => { export const MotdModal: React.FC<MotdModalProps> = ({ show, onDismiss }) => {
useTranslation() useTranslation()
const contextValue = useMotdContextValue()
const { error, loading, value } = useAsync(fetchMotd) const lines = useMemo(() => {
const [dismissed, setDismissed] = useState(false) const rawLines = contextValue?.motdText.split('\n')
if (rawLines === undefined || rawLines.length === 0 || !show) {
const lines = useMemo(() => value?.motdText.split('\n') ?? [], [value?.motdText]) return []
const dismiss = useCallback(() => {
if (value?.lastModified) {
window.localStorage.setItem(MOTD_LOCAL_STORAGE_KEY, value.lastModified)
}
setDismissed(true)
}, [value])
useEffect(() => {
if (error) {
logger.error('Error while fetching motd', error)
}
}, [error])
if (process.env.NODE_ENV === 'test' && !loading && !value) {
return <span {...testId('loaded not visible')}></span>
} }
return rawLines
}, [contextValue?.motdText, show])
return ( return (
<CommonModal <CommonModal show={lines.length > 0} titleI18nKey={'motd.title'} onHide={onDismiss} {...cypressId('motd-modal')}>
show={lines.length > 0 && !loading && !error && !dismissed}
titleI18nKey={'motd.title'}
{...cypressId('motd-modal')}>
<Modal.Body> <Modal.Body>
<EditorToRendererCommunicatorContextProvider> <EditorToRendererCommunicatorContextProvider>
<RendererIframe <RendererIframe
@ -66,7 +52,7 @@ export const MotdModal: React.FC = () => {
</EditorToRendererCommunicatorContextProvider> </EditorToRendererCommunicatorContextProvider>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant={'success'} onClick={dismiss} {...testId('motd-dismiss')} {...cypressId('motd-dismiss')}> <Button variant={'success'} onClick={onDismiss} {...testId('motd-dismiss')} {...cypressId('motd-dismiss')}>
<Trans i18nKey={'common.dismiss'} /> <Trans i18nKey={'common.dismiss'} />
</Button> </Button>
</Modal.Footer> </Modal.Footer>

View file

@ -6,6 +6,7 @@
import { DropdownHeader } from '../dropdown-header' import { DropdownHeader } from '../dropdown-header'
import { VersionInfoHelpMenuEntry } from './instance/version-info-help-menu-entry' import { VersionInfoHelpMenuEntry } from './instance/version-info-help-menu-entry'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { MotdModalHelpMenuEntry } from './instance/motd-modal-help-menu-entry'
/** /**
* Renders the instance submenu for the help dropdown. * Renders the instance submenu for the help dropdown.
@ -15,6 +16,7 @@ export const InstanceSubmenu: React.FC = () => {
<Fragment> <Fragment>
<DropdownHeader i18nKey={'appbar.help.instance.header'} /> <DropdownHeader i18nKey={'appbar.help.instance.header'} />
<VersionInfoHelpMenuEntry /> <VersionInfoHelpMenuEntry />
<MotdModalHelpMenuEntry />
</Fragment> </Fragment>
) )
} }

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment } from 'react'
import { TranslatedDropdownItem } from '../../translated-dropdown-item'
import { InfoCircleFill as IconInfoCircleFill } from 'react-bootstrap-icons'
import { useBooleanState } from '../../../../../../../hooks/common/use-boolean-state'
import { MotdModal } from '../../../../../../global-dialogs/motd-modal/motd-modal'
import { useMotdContextValue } from '../../../../../../motd/motd-context'
/**
* Help menu entry for the motd modal.
* When no modal content is defined, the menu entry will not render.
*/
export const MotdModalHelpMenuEntry: React.FC = () => {
const [modalVisibility, showModal, closeModal] = useBooleanState(false)
const contextValue = useMotdContextValue()
if (!contextValue) {
return null
}
return (
<Fragment>
<TranslatedDropdownItem
icon={IconInfoCircleFill}
i18nKey={'appbar.help.instance.motdModal'}
onClick={showModal}
/>
<MotdModal show={modalVisibility} onDismiss={closeModal} />
</Fragment>
)
}

View file

@ -0,0 +1,25 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import { createContext, useContext } from 'react'
import React from 'react'
import type { MotdApiResponse } from '../global-dialogs/motd-modal/fetch-motd'
const motdContext = createContext<MotdApiResponse | undefined>(undefined)
export const useMotdContextValue = () => {
return useContext(motdContext)
}
interface MotdProviderProps extends PropsWithChildren {
motd: MotdApiResponse | undefined
}
export const MotdProvider: React.FC<MotdProviderProps> = ({ children, motd }) => {
return <motdContext.Provider value={motd}>{children}</motdContext.Provider>
}