diff --git a/cypress/fixtures/banner.txt b/cypress/fixtures/banner.txt deleted file mode 100644 index bb11f3e01..000000000 --- a/cypress/fixtures/banner.txt +++ /dev/null @@ -1 +0,0 @@ -This is the mock banner call diff --git a/cypress/fixtures/banner.txt.license b/cypress/fixtures/banner.txt.license deleted file mode 100644 index 078e5a9ac..000000000 --- a/cypress/fixtures/banner.txt.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - -SPDX-License-Identifier: CC0-1.0 diff --git a/cypress/integration/banner.spec.ts b/cypress/integration/banner.spec.ts deleted file mode 100644 index dde571d5c..000000000 --- a/cypress/integration/banner.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const BANNER_LOCAL_STORAGE_KEY = 'banner.lastModified' -const MOCK_LAST_MODIFIED = 'mockETag' -const bannerMockContent = 'This is the mock banner call' - -describe('Banner', () => { - beforeEach(() => { - cy.intercept({ - method: 'GET', - url: '/mock-backend/public/banner.txt' - }, { - statusCode: 200, - headers: { 'Last-Modified': MOCK_LAST_MODIFIED }, - body: bannerMockContent - }) - - cy.intercept({ - method: 'HEAD', - url: '/mock-backend/public/banner.txt' - }, { - statusCode: 200, - headers: { 'Last-Modified': MOCK_LAST_MODIFIED } - }) - .as('headBanner') - - cy.visit('/') - localStorage.removeItem(BANNER_LOCAL_STORAGE_KEY) - expect(localStorage.getItem(BANNER_LOCAL_STORAGE_KEY)).to.be.null - }) - - it('shows the correct alert banner text', () => { - cy.get('[data-cy="motd-banner"]') - .contains(bannerMockContent) - }) - - it('can be dismissed', () => { - cy.get('[data-cy="motd-banner"]') - .contains(bannerMockContent) - cy.get('button[data-cy="motd-dismiss"]') - .click() - .then(() => { - expect(localStorage.getItem(BANNER_LOCAL_STORAGE_KEY)) - .to - .equal(MOCK_LAST_MODIFIED) - }) - cy.get('[data-cy="no-motd-banner"]') - .should('exist') - cy.get('[data-cy="motd-banner"]') - .should('not.exist') - }) - - it('won\'t show again on reload', () => { - cy.get('[data-cy="motd-banner"]') - .contains(bannerMockContent) - cy.get('button[data-cy="motd-dismiss"]') - .click() - .then(() => { - expect(localStorage.getItem(BANNER_LOCAL_STORAGE_KEY)) - .to - .equal(MOCK_LAST_MODIFIED) - }) - cy.get('[data-cy="no-motd-banner"]') - .should('exist') - cy.get('[data-cy="motd-banner"]') - .should('not.exist') - cy.reload() - cy.get('main') - .should('exist') - cy.wait('@headBanner') - cy.get('[data-cy="no-motd-banner"]') - .should('exist') - cy.get('[data-cy="motd-banner"]') - .should('not.exist') - }) -}) diff --git a/cypress/integration/motd.spec.ts b/cypress/integration/motd.spec.ts new file mode 100644 index 000000000..818fc7ae5 --- /dev/null +++ b/cypress/integration/motd.spec.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified' +const MOCK_LAST_MODIFIED = 'mockETag' +const motdMockContent = 'This is the mock Motd call' + +describe('Motd', () => { + const mockExistingMotd = () => { + cy.intercept('GET', '/mock-backend/public/motd.txt', { + statusCode: 200, + headers: { 'Last-Modified': MOCK_LAST_MODIFIED }, + body: motdMockContent + }) + + cy.intercept('HEAD', '/mock-backend/public/motd.txt', { + statusCode: 200, + headers: { 'Last-Modified': MOCK_LAST_MODIFIED } + }) + } + + beforeEach(() => { + localStorage.removeItem(MOTD_LOCAL_STORAGE_KEY) + }) + + it('shows the correct alert Motd text', () => { + mockExistingMotd() + cy.visit('/') + cy.get('[data-cy="motd"]').contains(motdMockContent) + }) + + it('can be dismissed', () => { + mockExistingMotd() + cy.visit('/') + cy.get('[data-cy="motd"]').contains(motdMockContent) + cy.get('button[data-cy="motd-dismiss"]') + .click() + .then(() => { + expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).to.equal(MOCK_LAST_MODIFIED) + }) + cy.get('[data-cy="motd"]').should('not.exist') + }) + + it("won't show again after dismiss and reload", () => { + mockExistingMotd() + cy.visit('/') + cy.get('[data-cy="motd"]').contains(motdMockContent) + cy.get('button[data-cy="motd-dismiss"]') + .click() + .then(() => { + expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).to.equal(MOCK_LAST_MODIFIED) + }) + cy.get('[data-cy="motd"]').should('not.exist') + cy.reload() + cy.get('main').should('exist') + cy.get('[data-cy="motd"]').should('not.exist') + }) + + it("will show again after reload without dismiss", () => { + mockExistingMotd() + cy.visit('/') + cy.get('[data-cy="motd"]').contains(motdMockContent) + cy.reload() + cy.get('main').should('exist') + cy.get('[data-cy="motd"]').contains(motdMockContent) + }) + + it("won't show again after dismiss and page navigation", () => { + mockExistingMotd() + cy.visit('/') + cy.get('[data-cy="motd"]').contains(motdMockContent) + cy.get('button[data-cy="motd-dismiss"]') + .click() + .then(() => { + expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).to.equal(MOCK_LAST_MODIFIED) + }) + cy.get('[data-cy="motd"]').should('not.exist') + cy.get('#navLinkHistory').click() + cy.get('main').should('exist') + cy.get('[data-cy="motd"]').should('not.exist') + }) + + it("won't show if no file exists", () => { + cy.visit('/') + cy.get('main').should('exist') + cy.get('[data-cy="motd"]').should('not.exist') + }) +}) diff --git a/cypress/support/config.ts b/cypress/support/config.ts index c675b8ab1..18eff8d76 100644 --- a/cypress/support/config.ts +++ b/cypress/support/config.ts @@ -68,4 +68,12 @@ Cypress.Commands.add('loadConfig', (additionalConfig?: Partial) = beforeEach(() => { cy.loadConfig() + + cy.intercept('GET', '/mock-backend/public/motd.txt', { + body: '404 Not Found!', + statusCode: 404 + }) + cy.intercept('HEAD', '/mock-backend/public/motd.txt', { + statusCode: 404 + }) }) diff --git a/cypress/support/visit-test-editor.ts b/cypress/support/visit-test-editor.ts index 5ad10c8da..a7dce9010 100644 --- a/cypress/support/visit-test-editor.ts +++ b/cypress/support/visit-test-editor.ts @@ -20,8 +20,8 @@ beforeEach(() => { cy.intercept(`/mock-backend/api/private/notes/${ testNoteId }-get`, { "content": "", "metadata": { - "id": "ABC11", - "alias": "banner", + "id": "mock_note_id", + "alias": "mockNote", "version": 2, "viewCount": 0, "updateTime": "2021-04-24T09:27:51.000Z", diff --git a/locales/en.json b/locales/en.json index 73a7e0cf5..d7c99e2a1 100644 --- a/locales/en.json +++ b/locales/en.json @@ -455,6 +455,7 @@ "export": "Export", "refresh": "Refresh", "cancel": "Cancel", + "dismiss": "Dismiss", "ok": "OK", "close": "Close", "save": "Save", @@ -495,5 +496,8 @@ "other": "There was an error while registering your account. Just try it again." } } + }, + "motd": { + "title": "Information" } } diff --git a/public/mock-backend/api/private/config b/public/mock-backend/api/private/config index 15129549c..dc7f82e56 100644 --- a/public/mock-backend/api/private/config +++ b/public/mock-backend/api/private/config @@ -18,10 +18,6 @@ "name": "DEMO Corp", "logo": "/mock-backend/public/img/demo.png" }, - "banner": { - "text": "This is the test banner text", - "timestamp": "2020-05-22T20:46:08.962Z" - }, "customAuthNames": { "ldap": "FooBar", "oauth2": "Olaf2", diff --git a/public/mock-backend/public/banner.txt b/public/mock-backend/public/banner.txt deleted file mode 100644 index 0e0d4dacd..000000000 --- a/public/mock-backend/public/banner.txt +++ /dev/null @@ -1 +0,0 @@ -This is the test banner text diff --git a/public/mock-backend/public/motd.txt b/public/mock-backend/public/motd.txt new file mode 100644 index 000000000..b0a5168f3 --- /dev/null +++ b/public/mock-backend/public/motd.txt @@ -0,0 +1 @@ +This is the test motd text diff --git a/src/components/application-loader/initializers/fetch-and-set-banner.ts b/src/components/application-loader/initializers/fetch-and-set-banner.ts deleted file mode 100644 index 2b3db5776..000000000 --- a/src/components/application-loader/initializers/fetch-and-set-banner.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setBanner } from '../../../redux/banner/methods' -import { defaultFetchConfig } from '../../../api/utils' -import { Logger } from '../../../utils/logger' - -export const BANNER_LOCAL_STORAGE_KEY = 'banner.lastModified' -const log = new Logger('Banner') - -export const fetchAndSetBanner = async (customizeAssetsUrl: string): Promise => { - const cachedLastModified = window.localStorage.getItem(BANNER_LOCAL_STORAGE_KEY) - const bannerUrl = `${customizeAssetsUrl}banner.txt` - - if (cachedLastModified) { - const response = await fetch(bannerUrl, { - ...defaultFetchConfig, - method: 'HEAD' - }) - if (response.status !== 200) { - return - } - if (response.headers.get('Last-Modified') === cachedLastModified) { - setBanner({ - lastModified: cachedLastModified, - text: '' - }) - return - } - } - - const response = await fetch(bannerUrl, { - ...defaultFetchConfig - }) - - if (response.status !== 200) { - return - } - - const bannerText = await response.text() - - const lastModified = response.headers.get('Last-Modified') - if (!lastModified) { - log.warn("'Last-Modified' not found for banner.txt!") - } - - setBanner({ - lastModified: lastModified, - text: bannerText - }) -} diff --git a/src/components/application-loader/initializers/fetch-motd.ts b/src/components/application-loader/initializers/fetch-motd.ts new file mode 100644 index 000000000..fe746f93a --- /dev/null +++ b/src/components/application-loader/initializers/fetch-motd.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setMotd } from '../../../redux/motd/methods' +import { defaultFetchConfig } from '../../../api/utils' +import { Logger } from '../../../utils/logger' + +export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified' +const log = new Logger('Motd') + +/** + * Fetches the current motd from the backend and sets the content in the global application state. + * If the motd hasn't changed since the last time then the global application state won't be changed. + * To check if the motd has changed the "last modified" header from the request + * will be compared to the saved value from the browser's local storage. + * + * @param customizeAssetsUrl the URL where the motd.txt can be found. + * @return A promise that gets resolved if the motd was fetched successfully. + */ +export const fetchMotd = async (customizeAssetsUrl: string): Promise => { + const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY) + const motdUrl = `${customizeAssetsUrl}motd.txt` + + if (cachedLastModified) { + const response = await fetch(motdUrl, { + ...defaultFetchConfig, + method: 'HEAD' + }) + if (response.status !== 200) { + return + } + if (response.headers.get('Last-Modified') === cachedLastModified) { + return + } + } + + const response = await fetch(motdUrl, { + ...defaultFetchConfig + }) + + if (response.status !== 200) { + return + } + + const motdText = await response.text() + + const lastModified = response.headers.get('Last-Modified') + if (!lastModified) { + log.warn("'Last-Modified' not found for motd.txt!") + } + + setMotd(motdText, lastModified) +} diff --git a/src/components/application-loader/initializers/index.ts b/src/components/application-loader/initializers/index.ts index 354bf7756..f7a55cd4d 100644 --- a/src/components/application-loader/initializers/index.ts +++ b/src/components/application-loader/initializers/index.ts @@ -6,7 +6,7 @@ import { setUpI18n } from './i18n/i18n' import { refreshHistoryState } from '../../../redux/history/methods' -import { fetchAndSetBanner } from './fetch-and-set-banner' +import { fetchMotd } from './fetch-motd' import { setApiUrl } from '../../../redux/api-url/methods' import { fetchAndSetUser } from '../../login-page/auth/utils' import { fetchFrontendConfig } from './fetch-frontend-config' @@ -47,8 +47,8 @@ export const createSetUpTaskList = ( task: fetchAndSetUser() }, { - name: 'Banner', - task: fetchAndSetBanner(customizeAssetsUrl) + name: 'Motd', + task: fetchMotd(customizeAssetsUrl) }, { name: 'Load history state', diff --git a/src/components/common/modals/common-modal.tsx b/src/components/common/modals/common-modal.tsx index c7fb4d471..36ab2c74f 100644 --- a/src/components/common/modals/common-modal.tsx +++ b/src/components/common/modals/common-modal.tsx @@ -13,7 +13,7 @@ import { ShowIf } from '../show-if/show-if' export interface CommonModalProps { show: boolean - onHide: () => void + onHide?: () => void titleI18nKey?: string title?: string closeButton?: boolean diff --git a/src/components/common/motd-banner/motd-banner.tsx b/src/components/common/motd-banner/motd-banner.tsx deleted file mode 100644 index 52db86f27..000000000 --- a/src/components/common/motd-banner/motd-banner.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import React, { useCallback } from 'react' -import { Alert, Button } from 'react-bootstrap' -import { setBanner } from '../../../redux/banner/methods' -import { ForkAwesomeIcon } from '../fork-awesome/fork-awesome-icon' -import { BANNER_LOCAL_STORAGE_KEY } from '../../application-loader/initializers/fetch-and-set-banner' -import { useApplicationState } from '../../../hooks/common/use-application-state' - -export const MotdBanner: React.FC = () => { - const bannerState = useApplicationState((state) => state.banner) - - const dismissBanner = useCallback(() => { - if (bannerState.lastModified) { - window.localStorage.setItem(BANNER_LOCAL_STORAGE_KEY, bannerState.lastModified) - } - setBanner({ - text: '', - lastModified: null - }) - }, [bannerState]) - - if (bannerState.text === undefined) { - return null - } - - if (!bannerState.text) { - return - } - - return ( - - {bannerState.text} - - - ) -} diff --git a/src/components/common/motd-modal/motd-modal.tsx b/src/components/common/motd-modal/motd-modal.tsx new file mode 100644 index 000000000..781a69af5 --- /dev/null +++ b/src/components/common/motd-modal/motd-modal.tsx @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { Fragment, useCallback, useMemo } from 'react' +import { Button, Modal } from 'react-bootstrap' +import { CommonModal } from '../modals/common-modal' +import { Trans, useTranslation } from 'react-i18next' +import { useApplicationState } from '../../../hooks/common/use-application-state' +import { dismissMotd } from '../../../redux/motd/methods' + +/** + * 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. + */ +export const MotdModal: React.FC = () => { + useTranslation() + const motdState = useApplicationState((state) => state.motd) + + const domContent = useMemo(() => { + if (!motdState) { + return null + } + return motdState.text + ?.split('\n') + .map((line) => {line}) + .reduce((previousLine, currentLine, currentLineIndex) => ( + + {previousLine} +
+ {currentLine} +
+ )) + }, [motdState]) + + const dismiss = useCallback(() => { + if (!motdState) { + return + } + dismissMotd() + }, [motdState]) + + if (motdState === null || motdState.dismissed) { + return null + } else { + return ( + + {domContent} + + + + + ) + } +} diff --git a/src/components/document-read-only-page/document-read-only-page.tsx b/src/components/document-read-only-page/document-read-only-page.tsx index f3f3afc71..dcd5b09e7 100644 --- a/src/components/document-read-only-page/document-read-only-page.tsx +++ b/src/components/document-read-only-page/document-read-only-page.tsx @@ -10,7 +10,7 @@ import { useParams } from 'react-router' import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode' import { useDocumentTitleWithNoteTitle } from '../../hooks/common/use-document-title-with-note-title' import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' -import { MotdBanner } from '../common/motd-banner/motd-banner' +import { MotdModal } from '../common/motd-modal/motd-modal' import { ShowIf } from '../common/show-if/show-if' import { AppBar, AppBarMode } from '../editor-page/app-bar/app-bar' import { EditorPagePathParams } from '../editor-page/editor-page' @@ -24,6 +24,7 @@ import { useApplicationState } from '../../hooks/common/use-application-state' import { useNoteMarkdownContentWithoutFrontmatter } from '../../hooks/common/use-note-markdown-content-without-frontmatter' import { EditorToRendererCommunicatorContextProvider } from '../editor-page/render-context/editor-to-renderer-communicator-context-provider' import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer' +import { UiNotifications } from '../notifications/ui-notifications' export const DocumentReadOnlyPage: React.FC = () => { useTranslation() @@ -40,8 +41,9 @@ export const DocumentReadOnlyPage: React.FC = () => { return ( + +
-
diff --git a/src/components/editor-page/editor-page.tsx b/src/components/editor-page/editor-page.tsx index 4485acd75..7f26081c1 100644 --- a/src/components/editor-page/editor-page.tsx +++ b/src/components/editor-page/editor-page.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next' import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode' import { useDocumentTitleWithNoteTitle } from '../../hooks/common/use-document-title-with-note-title' import { setCheckboxInMarkdownContent, updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' -import { MotdBanner } from '../common/motd-banner/motd-banner' +import { MotdModal } from '../common/motd-modal/motd-modal' import { ShowIf } from '../common/show-if/show-if' import { ErrorWhileLoadingNoteAlert } from '../document-read-only-page/ErrorWhileLoadingNoteAlert' import { LoadingNoteAlert } from '../document-read-only-page/LoadingNoteAlert' @@ -126,7 +126,7 @@ export const EditorPage: React.FC = () => { return ( - +
diff --git a/src/components/landing-layout/landing-layout.tsx b/src/components/landing-layout/landing-layout.tsx index b9a127d30..03ca265f4 100644 --- a/src/components/landing-layout/landing-layout.tsx +++ b/src/components/landing-layout/landing-layout.tsx @@ -7,7 +7,7 @@ import React, { Fragment } from 'react' import { Container } from 'react-bootstrap' import { useDocumentTitle } from '../../hooks/common/use-document-title' -import { MotdBanner } from '../common/motd-banner/motd-banner' +import { MotdModal } from '../common/motd-modal/motd-modal' import { Footer } from './footer/footer' import { HeaderBar } from './navigation/header-bar/header-bar' import { UiNotifications } from '../notifications/ui-notifications' @@ -18,8 +18,8 @@ export const LandingLayout: React.FC = ({ children }) => { return ( + -
{children}
diff --git a/src/redux/banner/methods.ts b/src/redux/banner/methods.ts deleted file mode 100644 index 424888623..000000000 --- a/src/redux/banner/methods.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { store } from '..' -import { BannerActionType, BannerState, SetBannerAction } from './types' - -export const setBanner = (state: BannerState): void => { - store.dispatch({ - type: BannerActionType.SET_BANNER, - state - } as SetBannerAction) -} diff --git a/src/redux/banner/reducers.ts b/src/redux/banner/reducers.ts deleted file mode 100644 index 69d14ebbc..000000000 --- a/src/redux/banner/reducers.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Reducer } from 'redux' -import { BannerActions, BannerActionType, BannerState } from './types' - -export const initialState: BannerState = { - text: undefined, - lastModified: null -} - -export const BannerReducer: Reducer = ( - state: BannerState = initialState, - action: BannerActions -) => { - switch (action.type) { - case BannerActionType.SET_BANNER: - return action.state - default: - return state - } -} diff --git a/src/redux/banner/types.ts b/src/redux/banner/types.ts deleted file mode 100644 index 01c92ecf4..000000000 --- a/src/redux/banner/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Action } from 'redux' - -export enum BannerActionType { - SET_BANNER = 'banner/set' -} - -export type BannerActions = SetBannerAction - -export interface SetBannerAction extends Action { - type: BannerActionType.SET_BANNER - state: BannerState -} - -export interface BannerState { - text: string | undefined - lastModified: string | null -} diff --git a/src/redux/index.ts b/src/redux/index.ts index e284699e4..cd2156b2b 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -8,8 +8,8 @@ import { combineReducers, createStore, Reducer } from 'redux' import { Config } from '../api/config/types' import { ApiUrlReducer } from './api-url/reducers' import { ApiUrlObject } from './api-url/types' -import { BannerReducer } from './banner/reducers' -import { BannerState } from './banner/types' +import { MotdReducer } from './motd/reducers' +import { OptionalMotdState } from './motd/types' import { ConfigReducer } from './config/reducers' import { DarkModeConfigReducer } from './dark-mode/reducers' import { DarkModeConfig } from './dark-mode/types' @@ -29,7 +29,7 @@ import { RendererStatus } from './renderer-status/types' export interface ApplicationState { user: OptionalUserState config: Config - banner: BannerState + motd: OptionalMotdState history: HistoryEntry[] apiUrl: ApiUrlObject editorConfig: EditorConfig @@ -42,7 +42,7 @@ export interface ApplicationState { export const allReducers: Reducer = combineReducers({ user: UserReducer, config: ConfigReducer, - banner: BannerReducer, + motd: MotdReducer, apiUrl: ApiUrlReducer, history: HistoryReducer, editorConfig: EditorConfigReducer, diff --git a/src/redux/motd/methods.ts b/src/redux/motd/methods.ts new file mode 100644 index 000000000..098860c07 --- /dev/null +++ b/src/redux/motd/methods.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { store } from '..' +import { DismissMotdAction, MotdActionType, SetMotdAction } from './types' + +/** + * Sets a not-dismissed motd message in the global application state. + * + * @param text The motd text content + * @param lastModified An identifier that describes when the motd was changed the last time. + */ +export const setMotd = (text: string, lastModified: string | null): void => { + store.dispatch({ + type: MotdActionType.SET_MOTD, + text, + lastModified + } as SetMotdAction) +} + +/** + * Dismisses the currently saved motd message. + */ +export const dismissMotd = (): void => { + store.dispatch({ + type: MotdActionType.DISMISS_MOTD + } as DismissMotdAction) +} diff --git a/src/redux/motd/reducers.ts b/src/redux/motd/reducers.ts new file mode 100644 index 000000000..34b470720 --- /dev/null +++ b/src/redux/motd/reducers.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Reducer } from 'redux' +import { MotdActions, MotdActionType, MotdState, OptionalMotdState } from './types' +import { MOTD_LOCAL_STORAGE_KEY } from '../../components/application-loader/initializers/fetch-motd' + +/** + * A reducer that modifies the {@link OptionalMotdState motd state} in the global application state. + */ +export const MotdReducer: Reducer = ( + state: OptionalMotdState = null, + action: MotdActions +) => { + switch (action.type) { + case MotdActionType.SET_MOTD: + return createNewMotdState(action.text, action.lastModified) + case MotdActionType.DISMISS_MOTD: + return createDismissedMotdState(state) + default: + return state + } +} + +/** + * Creates a new {@link MotdState motd state} by copying the old state and setting the dismissed flag. + * It also writes the "last-modified" identifier into the browser's local storage. + * + * @param oldState The current motd state that should be copied + * @return The new state + */ +const createDismissedMotdState = (oldState: OptionalMotdState): OptionalMotdState => { + if (oldState === null) { + return null + } else { + if (oldState.lastModified) { + window.localStorage.setItem(MOTD_LOCAL_STORAGE_KEY, oldState.lastModified) + } + return { + ...oldState, + dismissed: true + } + } +} + +/** + * Creates a new not-dismissed motd state. + * @param text The motd text + * @param lastModified An identifier that describes when the motd text was changed the last time + */ +const createNewMotdState = (text: string, lastModified: string | null): MotdState => { + return { + text, + lastModified, + dismissed: false + } +} diff --git a/src/redux/motd/types.ts b/src/redux/motd/types.ts new file mode 100644 index 000000000..338bd44e2 --- /dev/null +++ b/src/redux/motd/types.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Action } from 'redux' + +export enum MotdActionType { + SET_MOTD = 'motd/set', + DISMISS_MOTD = 'motd/dismiss' +} + +export type MotdActions = SetMotdAction | DismissMotdAction + +export interface SetMotdAction extends Action { + type: MotdActionType.SET_MOTD + text: string + lastModified: string | null +} + +export interface DismissMotdAction extends Action { + type: MotdActionType.DISMISS_MOTD +} + +export interface MotdState { + text: string + lastModified: string | null + dismissed: boolean +} + +export type OptionalMotdState = MotdState | null