diff --git a/frontend/cypress/e2e/motd.spec.ts b/frontend/cypress/e2e/motd.spec.ts
index 6cf7dfd33..7507af047 100644
--- a/frontend/cypress/e2e/motd.spec.ts
+++ b/frontend/cypress/e2e/motd.spec.ts
@@ -5,29 +5,18 @@
*/
const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
-const MOCK_LAST_MODIFIED = 'mockETag'
-const motdMockContent = 'This is the **mock** Motd call'
-const motdMockHtml = 'This is the mock Motd call'
+const motdMockHtml = 'This is the test motd text'
describe('Motd', () => {
it("shows, dismisses and won't show again a motd modal", () => {
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.getSimpleRendererBody().should('contain.html', motdMockHtml)
+ cy.getSimpleRendererBody().should('contain.text', motdMockHtml)
cy.getByCypressId('motd-dismiss')
.click()
.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.reload()
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 3494943e4..83cf6c09b 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -506,7 +506,8 @@
},
"instance": {
"header": "About this instance",
- "versionInfo": "Running version"
+ "versionInfo": "Running version",
+ "motdModal": "Show instance announcement"
},
"legal": {
"header": "Legal",
@@ -596,7 +597,7 @@
}
},
"motd": {
- "title": "Information"
+ "title": "Announcement"
},
"errors": {
"notFound": {
diff --git a/frontend/src/app/(editor)/layout.tsx b/frontend/src/app/(editor)/layout.tsx
index 57c9fb440..6b5f6bd11 100644
--- a/frontend/src/app/(editor)/layout.tsx
+++ b/frontend/src/app/(editor)/layout.tsx
@@ -7,7 +7,6 @@ import '../../../global-styles/index.scss'
import { ApplicationLoader } from '../../components/application-loader/application-loader'
import { BaseUrlContextProvider } from '../../components/common/base-url/base-url-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 { ExpectedOriginBoundary } from '../../components/layout/expected-origin-boundary'
import { UiNotificationBoundary } from '../../components/notifications/ui-notification-boundary'
@@ -18,6 +17,9 @@ import type { Metadata } from 'next'
import type { PropsWithChildren } from 'react'
import React from 'react'
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()
@@ -28,6 +30,7 @@ interface RootLayoutProps extends PropsWithChildren {
export default async function RootLayout({ children, appBar }: RootLayoutProps) {
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls()
const frontendConfig = await getConfig(baseUrls.editor)
+ const motd = await fetchMotd(baseUrls.internalApiUrl ?? baseUrls.editor)
return (
@@ -37,20 +40,22 @@ export default async function RootLayout({ children, appBar }: RootLayoutProps)
-
-
-
-
-
-
-
- {appBar}
- {children}
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {appBar}
+ {children}
+
+
+
+
+
+
diff --git a/frontend/src/components/global-dialogs/motd-modal/cached-motd-modal.tsx b/frontend/src/components/global-dialogs/motd-modal/cached-motd-modal.tsx
new file mode 100644
index 000000000..96a21eeac
--- /dev/null
+++ b/frontend/src/components/global-dialogs/motd-modal/cached-motd-modal.tsx
@@ -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(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
+ }
+
+ return
+}
diff --git a/frontend/src/components/global-dialogs/motd-modal/fetch-motd.spec.ts b/frontend/src/components/global-dialogs/motd-modal/fetch-motd.spec.ts
index 4cc5426b2..dc39fe9c6 100644
--- a/frontend/src/components/global-dialogs/motd-modal/fetch-motd.spec.ts
+++ b/frontend/src/components/global-dialogs/motd-modal/fetch-motd.spec.ts
@@ -7,7 +7,8 @@ import { fetchMotd } from './fetch-motd'
import { Mock } from 'ts-mockery'
describe('fetch motd', () => {
- const motdUrl = '/public/motd.md'
+ const baseUrl = 'https://example.org/'
+ const motdUrl = `${baseUrl}public/motd.md`
beforeEach(() => {
window.localStorage.clear()
@@ -47,7 +48,7 @@ describe('fetch motd', () => {
jest.spyOn(global, 'fetch').mockImplementation(() =>
Promise.resolve(
Mock.of({
- status: 500
+ status: 404
})
)
)
@@ -56,7 +57,7 @@ describe('fetch motd', () => {
describe('date detection', () => {
it('will return the last-modified value if available', async () => {
mockFetch('mocked motd', 'yesterday-modified', null)
- const result = fetchMotd()
+ const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd',
lastModified: 'yesterday-modified'
@@ -64,7 +65,7 @@ describe('fetch motd', () => {
})
it('will return the etag if last-modified is not returned', async () => {
mockFetch('mocked motd', null, 'yesterday-etag')
- const result = fetchMotd()
+ const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd',
lastModified: 'yesterday-etag'
@@ -72,7 +73,7 @@ describe('fetch motd', () => {
})
it('will prefer the last-modified header over the etag', async () => {
mockFetch('mocked motd', 'yesterday-last', 'yesterday-etag')
- const result = fetchMotd()
+ const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd',
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 () => {
mockFetch('mocked motd', null, null)
- const result = fetchMotd()
- await expect(result).resolves.toStrictEqual({
- motdText: 'mocked motd',
- lastModified: null
- })
+ const result = fetchMotd(baseUrl)
+ await expect(result).resolves.toBe(undefined)
})
})
it('can fetch a motd if no last modified value has been memorized', async () => {
mockFetch('mocked motd', 'yesterday')
- const result = fetchMotd()
+ const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd',
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 () => {
mockFetch('mocked motd', 'yesterday')
window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
- const result = fetchMotd()
+ const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual({
motdText: 'mocked motd',
lastModified: 'yesterday'
@@ -116,14 +107,14 @@ describe('fetch motd', () => {
it("won't fetch a motd if no file was found", async () => {
mockFileNotFoundFetch()
- const result = fetchMotd()
+ const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual(undefined)
})
it("won't fetch a motd update if no file was found", async () => {
mockFileNotFoundFetch()
window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
- const result = fetchMotd()
+ const result = fetchMotd(baseUrl)
await expect(result).resolves.toStrictEqual(undefined)
})
})
diff --git a/frontend/src/components/global-dialogs/motd-modal/fetch-motd.ts b/frontend/src/components/global-dialogs/motd-modal/fetch-motd.ts
index 6c2f6f018..a3474be5a 100644
--- a/frontend/src/components/global-dialogs/motd-modal/fetch-motd.ts
+++ b/frontend/src/components/global-dialogs/motd-modal/fetch-motd.ts
@@ -4,14 +4,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defaultConfig } from '../../../api/common/default-config'
-import { Logger } from '../../../utils/logger'
export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
-const log = new Logger('Motd')
export interface MotdApiResponse {
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.
* @return A promise that gets resolved if the motd was fetched successfully.
*/
-export const fetchMotd = async (): Promise => {
- const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)
- 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
- }
- }
-
+export const fetchMotd = async (baseUrl: string): Promise => {
+ const motdUrl = `${baseUrl}public/motd.md`
const response = await fetch(motdUrl, {
...defaultConfig
})
if (response.status !== 200) {
- return undefined
+ return
}
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
- if (!lastModified) {
- log.warn("'Last-Modified' or 'Etag' not found for motd.md!")
+ if (lastModified === null) {
+ return
}
- return { motdText: await response.text(), lastModified }
+ return {
+ lastModified,
+ motdText: await response.text()
+ }
}
diff --git a/frontend/src/components/global-dialogs/motd-modal/motd-modal.spec.tsx b/frontend/src/components/global-dialogs/motd-modal/motd-modal.spec.tsx
index a26caf4d1..16b798eac 100644
--- a/frontend/src/components/global-dialogs/motd-modal/motd-modal.spec.tsx
+++ b/frontend/src/components/global-dialogs/motd-modal/motd-modal.spec.tsx
@@ -9,13 +9,12 @@ import { testId } from '../../../utils/test-id'
import type { CommonModalProps } from '../../common/modals/common-modal'
import * as CommonModalModule from '../../common/modals/common-modal'
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 type { PropsWithChildren } 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/renderer-iframe/renderer-iframe')
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 () => {
- jest.spyOn(fetchMotdModule, 'fetchMotd').mockImplementation(() => {
- return Promise.resolve({
- motdText: 'very important mock text!',
- lastModified: 'yesterday'
- })
- })
- const view = render()
+ const motd = {
+ motdText: 'very important mock text!',
+ lastModified: 'yesterday'
+ }
+ const view = render(
+
+
+
+ )
await screen.findByTestId('motd-renderer')
expect(view.container).toMatchSnapshot()
@@ -67,10 +68,11 @@ describe('motd modal', () => {
})
it("doesn't render a modal if no motd has been fetched", async () => {
- jest.spyOn(fetchMotdModule, 'fetchMotd').mockImplementation(() => {
- return Promise.resolve(undefined)
- })
- const view = render()
+ const view = render(
+
+
+
+ )
await screen.findByTestId('loaded not visible')
expect(view.container).toMatchSnapshot()
})
diff --git a/frontend/src/components/global-dialogs/motd-modal/motd-modal.tsx b/frontend/src/components/global-dialogs/motd-modal/motd-modal.tsx
index d732f8634..095be3b48 100644
--- a/frontend/src/components/global-dialogs/motd-modal/motd-modal.tsx
+++ b/frontend/src/components/global-dialogs/motd-modal/motd-modal.tsx
@@ -5,55 +5,41 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { cypressId } from '../../../utils/cypress-attribute'
-import { Logger } from '../../../utils/logger'
import { testId } from '../../../utils/test-id'
import { CommonModal } from '../../common/modals/common-modal'
import { RendererIframe } from '../../common/renderer-iframe/renderer-iframe'
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 { fetchMotd, MOTD_LOCAL_STORAGE_KEY } from './fetch-motd'
-import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import React, { useMemo } from 'react'
import { Button, Modal } from 'react-bootstrap'
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.
- * 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.
+ * Shows the MotD that is provided via context.
+ *
+ * @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 = ({ show, onDismiss }) => {
useTranslation()
+ const contextValue = useMotdContextValue()
- const { error, loading, value } = useAsync(fetchMotd)
- const [dismissed, setDismissed] = useState(false)
-
- const lines = useMemo(() => value?.motdText.split('\n') ?? [], [value?.motdText])
-
- const dismiss = useCallback(() => {
- if (value?.lastModified) {
- window.localStorage.setItem(MOTD_LOCAL_STORAGE_KEY, value.lastModified)
+ const lines = useMemo(() => {
+ const rawLines = contextValue?.motdText.split('\n')
+ if (rawLines === undefined || rawLines.length === 0 || !show) {
+ return []
}
- setDismissed(true)
- }, [value])
-
- useEffect(() => {
- if (error) {
- logger.error('Error while fetching motd', error)
- }
- }, [error])
-
- if (process.env.NODE_ENV === 'test' && !loading && !value) {
- return
- }
+ return rawLines
+ }, [contextValue?.motdText, show])
return (
- 0 && !loading && !error && !dismissed}
- titleI18nKey={'motd.title'}
- {...cypressId('motd-modal')}>
+ 0} titleI18nKey={'motd.title'} onHide={onDismiss} {...cypressId('motd-modal')}>
{
-
diff --git a/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/instance-submenu.tsx b/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/instance-submenu.tsx
index 3e011cd29..fa5a864cc 100644
--- a/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/instance-submenu.tsx
+++ b/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/instance-submenu.tsx
@@ -6,6 +6,7 @@
import { DropdownHeader } from '../dropdown-header'
import { VersionInfoHelpMenuEntry } from './instance/version-info-help-menu-entry'
import React, { Fragment } from 'react'
+import { MotdModalHelpMenuEntry } from './instance/motd-modal-help-menu-entry'
/**
* Renders the instance submenu for the help dropdown.
@@ -15,6 +16,7 @@ export const InstanceSubmenu: React.FC = () => {
+
)
}
diff --git a/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/instance/motd-modal-help-menu-entry.tsx b/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/instance/motd-modal-help-menu-entry.tsx
new file mode 100644
index 000000000..942ffc0a0
--- /dev/null
+++ b/frontend/src/components/layout/app-bar/app-bar-elements/help-dropdown/submenues/instance/motd-modal-help-menu-entry.tsx
@@ -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 (
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/motd/motd-context.tsx b/frontend/src/components/motd/motd-context.tsx
new file mode 100644
index 000000000..4a79ff59a
--- /dev/null
+++ b/frontend/src/components/motd/motd-context.tsx
@@ -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(undefined)
+
+export const useMotdContextValue = () => {
+ return useContext(motdContext)
+}
+
+interface MotdProviderProps extends PropsWithChildren {
+ motd: MotdApiResponse | undefined
+}
+
+export const MotdProvider: React.FC = ({ children, motd }) => {
+ return {children}
+}