From a86789dbefe8fc67f3b514fbfeeb4c0520b01382 Mon Sep 17 00:00:00 2001 From: Tilman Vatteroth Date: Thu, 11 Mar 2021 20:51:11 +0100 Subject: [PATCH] Add toasts (#1073) Signed-off-by: Tilman Vatteroth --- src/components/editor-page/editor-page.tsx | 5 + .../editor-page/use-notification-test.tsx | 24 +++++ .../landing-layout/landing-layout.tsx | 26 +++--- .../notifications/notifications.scss | 22 +++++ .../notifications/ui-notification-toast.tsx | 92 +++++++++++++++++++ .../notifications/ui-notifications.tsx | 34 +++++++ src/redux/index.ts | 6 +- src/redux/ui-notifications/methods.ts | 39 ++++++++ src/redux/ui-notifications/reducers.ts | 36 ++++++++ src/redux/ui-notifications/types.ts | 45 +++++++++ src/style/variables.light.scss | 4 +- 11 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 src/components/editor-page/use-notification-test.tsx create mode 100644 src/components/notifications/notifications.scss create mode 100644 src/components/notifications/ui-notification-toast.tsx create mode 100644 src/components/notifications/ui-notifications.tsx create mode 100644 src/redux/ui-notifications/methods.ts create mode 100644 src/redux/ui-notifications/reducers.ts create mode 100644 src/redux/ui-notifications/types.ts diff --git a/src/components/editor-page/editor-page.tsx b/src/components/editor-page/editor-page.tsx index 9f0e8cbf1..35493b064 100644 --- a/src/components/editor-page/editor-page.tsx +++ b/src/components/editor-page/editor-page.tsx @@ -32,6 +32,8 @@ import { Splitter } from './splitter/splitter' import { DualScrollState, ScrollState } from './synced-scroll/scroll-props' import { RendererType } from '../render-page/rendering-message' import { useEditorModeFromUrl } from './hooks/useEditorModeFromUrl' +import { UiNotifications } from '../notifications/ui-notifications' +import { useNotificationTest } from './use-notification-test' export interface EditorPagePathParams { id: string @@ -82,8 +84,11 @@ export const EditorPage: React.FC = () => { scrollSource.current = ScrollSource.EDITOR }, []) + useNotificationTest() + return ( +
diff --git a/src/components/editor-page/use-notification-test.tsx b/src/components/editor-page/use-notification-test.tsx new file mode 100644 index 000000000..6430940f9 --- /dev/null +++ b/src/components/editor-page/use-notification-test.tsx @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect } from 'react' +import { DEFAULT_DURATION_IN_SECONDS, dispatchUiNotification } from '../../redux/ui-notifications/methods' + +const localStorageKey = 'dontshowtestnotification' + +export const useNotificationTest = (): void => { + useEffect(() => { + if (window.localStorage.getItem(localStorageKey)) { + return + } + console.debug('[Notifications] Dispatched test notification') + dispatchUiNotification('Notification-Test!', 'It Works!', DEFAULT_DURATION_IN_SECONDS, 'info-circle', [{ + label: 'Don\'t show again', onClick: () => { + window.localStorage.setItem(localStorageKey, '1') + } + }]) + }, []) +} diff --git a/src/components/landing-layout/landing-layout.tsx b/src/components/landing-layout/landing-layout.tsx index 37ee7bc84..2521d11b9 100644 --- a/src/components/landing-layout/landing-layout.tsx +++ b/src/components/landing-layout/landing-layout.tsx @@ -4,26 +4,30 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React from 'react' +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 { Footer } from './footer/footer' import { HeaderBar } from './navigation/header-bar/header-bar' +import { UiNotifications } from '../notifications/ui-notifications' export const LandingLayout: React.FC = ({ children }) => { useDocumentTitle() return ( - - - -
-
- { children } -
-
-
-
+ + + + + +
+
+ { children } +
+
+
+
+
) } diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss new file mode 100644 index 000000000..ba8f7de4b --- /dev/null +++ b/src/components/notifications/notifications.scss @@ -0,0 +1,22 @@ +/*! + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +.notifications-area { + position: absolute; + top: 15px; + right: 15px; + z-index: 2000; + + .toast { + min-width: 250px; + max-width: 100vw; + } + + .progress { + border-radius: 0; + height: 0.25rem; + } +} diff --git a/src/components/notifications/ui-notification-toast.tsx b/src/components/notifications/ui-notification-toast.tsx new file mode 100644 index 000000000..1261f4cb6 --- /dev/null +++ b/src/components/notifications/ui-notification-toast.tsx @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Button, ProgressBar, Toast } from 'react-bootstrap' +import { UiNotification } from '../../redux/ui-notifications/types' +import { ForkAwesomeIcon } from '../common/fork-awesome/fork-awesome-icon' +import { ShowIf } from '../common/show-if/show-if' +import { IconName } from '../common/fork-awesome/types' +import { dismissUiNotification } from '../../redux/ui-notifications/methods' + +const STEPS_PER_SECOND = 10 + +export interface UiNotificationProps extends UiNotification { + notificationId: number +} + +export const UiNotificationToast: React.FC = ({ title, content, date, icon, dismissed, notificationId, durationInSecond, buttons }) => { + const [eta, setEta] = useState() + const interval = useRef(undefined) + + const deleteInterval = useCallback(() => { + if (interval.current) { + clearInterval(interval.current) + } + }, []) + + const dismissThisNotification = useCallback(() => { + console.debug(`[Notifications] Dismissed notification ${ notificationId }`) + dismissUiNotification(notificationId) + }, [notificationId]) + + useLayoutEffect(() => { + if (dismissed || !!interval.current) { + return + } + console.debug(`[Notifications] Show notification ${ notificationId }`) + setEta(durationInSecond * STEPS_PER_SECOND) + interval.current = setInterval(() => setEta((lastETA) => { + if (lastETA === undefined) { + return + } else if (lastETA <= 0) { + return 0 + } else { + return lastETA - 1 + } + }), 1000 / STEPS_PER_SECOND) + return () => { + deleteInterval() + } + }, [deleteInterval, dismissThisNotification, dismissed, durationInSecond, notificationId]) + + useEffect(() => { + if (eta === 0) { + dismissThisNotification() + } + }, [dismissThisNotification, eta]) + + const buttonsDom = useMemo(() => buttons?.map(button => { + const buttonClick = () => { + button.onClick() + dismissThisNotification() + } + return + } + ), [buttons, dismissThisNotification]) + + return ( + + + + + + + { title } + + { date.toRelative({ style: 'short' }) } + + { content } + +
+ { + buttonsDom + } +
+
+ ) +} diff --git a/src/components/notifications/ui-notifications.tsx b/src/components/notifications/ui-notifications.tsx new file mode 100644 index 000000000..cde2b15de --- /dev/null +++ b/src/components/notifications/ui-notifications.tsx @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React from 'react' +import { UiNotificationToast } from './ui-notification-toast' +import './notifications.scss' +import { useSelector } from 'react-redux' +import { ApplicationState } from '../../redux' +import equal from 'fast-deep-equal' + +export const UiNotifications: React.FC = () => { + const notifications = useSelector((state: ApplicationState) => state.uiNotifications, equal) + + return ( +
+ { + notifications.map((notification, notificationIndex) => + ) + } +
+ ) +} + + + diff --git a/src/redux/index.ts b/src/redux/index.ts index 9b5349d6e..3bcd38eed 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -19,6 +19,8 @@ import { NoteDetailsReducer } from './note-details/reducers' import { NoteDetails } from './note-details/types' import { UserReducer } from './user/reducers' import { MaybeUserState } from './user/types' +import { UiNotificationState } from './ui-notifications/types' +import { UiNotificationReducer } from './ui-notifications/reducers' export interface ApplicationState { user: MaybeUserState; @@ -28,6 +30,7 @@ export interface ApplicationState { editorConfig: EditorConfig; darkMode: DarkModeConfig; noteDetails: NoteDetails; + uiNotifications: UiNotificationState; } export const allReducers: Reducer = combineReducers({ @@ -37,7 +40,8 @@ export const allReducers: Reducer = combineReducers { + store.dispatch({ + type: UiNotificationActionType.DISPATCH_NOTIFICATION, + notification: { + title, + content, + date: DateTime.now(), + dismissed: false, + icon, + durationInSecond, + buttons: buttons + } + } as DispatchUiNotificationAction) +} + +export const dismissUiNotification = (notificationId: number): void => { + store.dispatch({ + type: UiNotificationActionType.DISMISS_NOTIFICATION, + notificationId + } as DismissUiNotificationAction) +} diff --git a/src/redux/ui-notifications/reducers.ts b/src/redux/ui-notifications/reducers.ts new file mode 100644 index 000000000..595d197c9 --- /dev/null +++ b/src/redux/ui-notifications/reducers.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + + +import { Reducer } from 'redux' +import { + DismissUiNotificationAction, + DispatchUiNotificationAction, + UiNotificationAction, + UiNotificationActionType, + UiNotificationState +} from './types' + +export const UiNotificationReducer: Reducer = (state: UiNotificationState = [], action: UiNotificationAction) => { + switch (action.type) { + case UiNotificationActionType.DISPATCH_NOTIFICATION: + return state.concat((action as DispatchUiNotificationAction).notification) + case UiNotificationActionType.DISMISS_NOTIFICATION: + return dismissNotification(state, (action as DismissUiNotificationAction).notificationId) + default: + return state + } +} + +const dismissNotification = (notificationState: UiNotificationState, notificationIndex: number): UiNotificationState => { + const newArray = [...notificationState] + const oldNotification = newArray[notificationIndex] + newArray[notificationIndex] = { + ...oldNotification, + dismissed: true + } + return newArray +} diff --git a/src/redux/ui-notifications/types.ts b/src/redux/ui-notifications/types.ts new file mode 100644 index 000000000..a930ffaed --- /dev/null +++ b/src/redux/ui-notifications/types.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Action } from 'redux' +import { DateTime } from 'luxon' +import { IconName } from '../../components/common/fork-awesome/types' + +export enum UiNotificationActionType { + DISPATCH_NOTIFICATION = 'notification/dispatch', + DISMISS_NOTIFICATION = 'notification/dismiss' +} + +export interface UiNotificationButton { + label: string, + onClick: () => void +} + +export interface UiNotification { + title: string + date: DateTime + content: string + dismissed: boolean + icon?: IconName + durationInSecond: number + buttons?: UiNotificationButton[] +} + +export interface UiNotificationAction extends Action { + type: UiNotificationActionType +} + +export interface DispatchUiNotificationAction extends UiNotificationAction { + type: UiNotificationActionType.DISPATCH_NOTIFICATION + notification: UiNotification +} + +export interface DismissUiNotificationAction extends UiNotificationAction { + type: UiNotificationActionType.DISMISS_NOTIFICATION + notificationId: number +} + +export type UiNotificationState = UiNotification[] diff --git a/src/style/variables.light.scss b/src/style/variables.light.scss index c5da997b0..e88f864fa 100644 --- a/src/style/variables.light.scss +++ b/src/style/variables.light.scss @@ -1,4 +1,4 @@ -/* +/*! * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only @@ -12,3 +12,5 @@ $dark: #222222 !default; @import "../../node_modules/bootstrap/scss/mixins"; @import "../../node_modules/bootstrap/scss/variables"; +$toast-background-color: $white; +