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} +}