Add toasts (#1073)

Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
This commit is contained in:
Tilman Vatteroth 2021-03-11 20:51:11 +01:00 committed by GitHub
parent 0b4a0afa16
commit a86789dbef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 320 additions and 13 deletions

View file

@ -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 (
<Fragment>
<UiNotifications/>
<MotdBanner/>
<div className={ 'd-flex flex-column vh-100' }>
<AppBar mode={ AppBarMode.EDITOR }/>

View file

@ -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')
}
}])
}, [])
}

View file

@ -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 (
<Container className="text-light d-flex flex-column mvh-100">
<MotdBanner/>
<HeaderBar/>
<div className={ 'd-flex flex-column justify-content-between flex-fill text-center' }>
<main>
{ children }
</main>
<Footer/>
</div>
</Container>
<Fragment>
<UiNotifications/>
<Container className="text-light d-flex flex-column mvh-100">
<MotdBanner/>
<HeaderBar/>
<div className={ 'd-flex flex-column justify-content-between flex-fill text-center' }>
<main>
{ children }
</main>
<Footer/>
</div>
</Container>
</Fragment>
)
}

View file

@ -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;
}
}

View file

@ -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<UiNotificationProps> = ({ title, content, date, icon, dismissed, notificationId, durationInSecond, buttons }) => {
const [eta, setEta] = useState<number>()
const interval = useRef<NodeJS.Timeout | undefined>(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 <Button key={ button.label } size={ 'sm' } onClick={ buttonClick }
variant={ 'link' }>{ button.label }</Button>
}
), [buttons, dismissThisNotification])
return (
<Toast show={ !dismissed && eta !== undefined } onClose={ dismissThisNotification }>
<Toast.Header>
<strong className="mr-auto">
<ShowIf condition={ !!icon }>
<ForkAwesomeIcon icon={ icon as IconName } fixedWidth={ true } className={ 'mr-1' }/>
</ShowIf>
{ title }
</strong>
<small>{ date.toRelative({ style: 'short' }) }</small>
</Toast.Header>
<Toast.Body>{ content }</Toast.Body>
<ProgressBar variant={ 'info' } now={ eta } max={ durationInSecond * STEPS_PER_SECOND } min={ 0 }/>
<div>
{
buttonsDom
}
</div>
</Toast>
)
}

View file

@ -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 (
<div
className={ 'notifications-area' }
aria-live="polite"
aria-atomic="true">
{
notifications.map((notification, notificationIndex) =>
<UiNotificationToast
key={ notificationIndex }
notificationId={ notificationIndex }
{ ...notification }/>)
}
</div>
)
}

View file

@ -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<ApplicationState> = combineReducers<ApplicationState>({
@ -37,7 +40,8 @@ export const allReducers: Reducer<ApplicationState> = combineReducers<Applicatio
apiUrl: ApiUrlReducer,
editorConfig: EditorConfigReducer,
darkMode: DarkModeConfigReducer,
noteDetails: NoteDetailsReducer
noteDetails: NoteDetailsReducer,
uiNotifications: UiNotificationReducer
})
export const store = createStore(allReducers)

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { store } from '../index'
import {
DismissUiNotificationAction,
DispatchUiNotificationAction,
UiNotificationActionType,
UiNotificationButton
} from './types'
import { DateTime } from 'luxon'
import { IconName } from '../../components/common/fork-awesome/types'
export const DEFAULT_DURATION_IN_SECONDS = 10
export const dispatchUiNotification = (title: string, content: string, durationInSecond = DEFAULT_DURATION_IN_SECONDS, icon?: IconName, buttons?: UiNotificationButton[]): void => {
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)
}

View file

@ -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<UiNotificationState, UiNotificationAction> = (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
}

View file

@ -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<UiNotificationActionType> {
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[]

View file

@ -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;