diff --git a/app/soapbox/actions/alerts.ts b/app/soapbox/actions/alerts.ts index 8f200563a..3e5aed4b3 100644 --- a/app/soapbox/actions/alerts.ts +++ b/app/soapbox/actions/alerts.ts @@ -5,7 +5,7 @@ import { httpErrorMessages } from 'soapbox/utils/errors'; import type { SnackbarActionSeverity } from './snackbar'; import type { AnyAction } from '@reduxjs/toolkit'; import type { AxiosError } from 'axios'; -import type { NotificationObject } from 'react-notification'; +import type { NotificationObject } from 'soapbox/react-notification'; const messages = defineMessages({ unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, diff --git a/app/soapbox/features/ui/containers/notifications_container.tsx b/app/soapbox/features/ui/containers/notifications_container.tsx index eafd8efe4..b6d1748e5 100644 --- a/app/soapbox/features/ui/containers/notifications_container.tsx +++ b/app/soapbox/features/ui/containers/notifications_container.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { useIntl, MessageDescriptor } from 'react-intl'; -import { NotificationStack, NotificationObject, StyleFactoryFn } from 'react-notification'; import { useHistory } from 'react-router-dom'; import { dismissAlert } from 'soapbox/actions/alerts'; import { Button } from 'soapbox/components/ui'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; +import { NotificationStack, NotificationObject, StyleFactoryFn } from 'soapbox/react-notification'; import type { Alert } from 'soapbox/reducers/alerts'; diff --git a/app/soapbox/hooks/useGdpr.ts b/app/soapbox/hooks/useGdpr.ts index ea0c5435f..49e66bf0c 100644 --- a/app/soapbox/hooks/useGdpr.ts +++ b/app/soapbox/hooks/useGdpr.ts @@ -11,6 +11,7 @@ const hasGdpr = !!localStorage.getItem('soapbox:gdpr'); const messages = defineMessages({ accept: { id: 'gdpr.accept', defaultMessage: 'Accept' }, + learnMore: { id: 'gdpr.learn_more', defaultMessage: 'Learn more' }, body: { id: 'gdpr.message', defaultMessage: '{siteTitle} uses session cookies, which are essential to the website\'s functioning.' }, }); diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index 7ed814013..d90112810 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -90,6 +90,7 @@ export const SoapboxConfigRecord = ImmutableRecord({ defaultSettings: ImmutableMap(), extensions: ImmutableMap(), gdpr: false, + gdprUrl: '', greentext: false, promoPanel: PromoPanelRecord(), navlinks: ImmutableMap({ diff --git a/app/soapbox/react-notification/defaultPropTypes.js b/app/soapbox/react-notification/defaultPropTypes.js new file mode 100644 index 000000000..1a3dd9d4e --- /dev/null +++ b/app/soapbox/react-notification/defaultPropTypes.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; + +export default { + message: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.element, + ]).isRequired, + action: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, + PropTypes.node, + ]), + onClick: PropTypes.func, + style: PropTypes.bool, + actionStyle: PropTypes.object, + titleStyle: PropTypes.object, + barStyle: PropTypes.object, + activeBarStyle: PropTypes.object, + dismissAfter: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.number, + ]), + onDismiss: PropTypes.func, + className: PropTypes.string, + activeClassName: PropTypes.string, + isActive: PropTypes.bool, + title: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.node, + ]), +}; diff --git a/app/soapbox/react-notification/index.d.ts b/app/soapbox/react-notification/index.d.ts new file mode 100644 index 000000000..7a1acd780 --- /dev/null +++ b/app/soapbox/react-notification/index.d.ts @@ -0,0 +1,88 @@ +declare module 'soapbox/react-notification' { + import { Component, ReactElement } from 'react'; + + interface StyleFactoryFn { + (index: number, style: object | void, notification: NotificationProps): object; + } + + interface OnClickNotificationProps { + /** + * Callback function to run when the action is clicked. + * @param notification Notification currently being clicked + * @param deactivate Function that can be called to set the notification to inactive. + * Used to activate notification exit animation on click. + */ + onClick?(notification: NotificationProps, deactivate: () => void): void; + } + + interface NotificationProps extends OnClickNotificationProps { + /** The name of the action, e.g., "close" or "undo". */ + action?: string; + /** Custom action styles. */ + actionStyle?: object; + /** Custom snackbar styles when the bar is active. */ + activeBarStyle?: object; + /** + * Custom class to apply to the top-level component when active. + * @default 'notification-bar-active' + */ + activeClassName?: string; + /** Custom snackbar styles. */ + barStyle?: object; + /** Custom class to apply to the top-level component. */ + className?: string; + /** + * Timeout for onDismiss event. + * @default 2000 + */ + dismissAfter?: boolean | number; + /** + * If true, the notification is visible. + * @default false + */ + isActive?: boolean; + /** The message or component for the notification. */ + message: string | ReactElement; + /** Setting this prop to `false` will disable all inline styles. */ + style?: boolean; + /** The title for the notification. */ + title?: string | ReactElement; + /** Custom title styles. */ + titleStyle?: object; + + /** + * Callback function to run when dismissAfter timer runs out + * @param notification Notification currently being dismissed. + */ + onDismiss?(notification: NotificationProps): void; + } + + interface NotificationStackProps extends OnClickNotificationProps { + /** Create the style of the actions. */ + actionStyleFactory?: StyleFactoryFn; + /** Create the style of the active notification. */ + activeBarStyleFactory?: StyleFactoryFn; + /** Create the style of the notification. */ + barStyleFactory?: StyleFactoryFn; + /** + * If false, notification dismiss timers start immediately. + * @default true + */ + dismissInOrder?: boolean; + /** Array of notifications to render. */ + notifications: NotificationObject[]; + /** + * Callback function to run when dismissAfter timer runs out + * @param notification Notification currently being dismissed. + */ + onDismiss?(notification: NotificationObject): void; + } + + export interface NotificationObject extends NotificationProps { + key: number | string; + } + + export class Notification extends Component {} + + export class NotificationStack extends Component {} +} diff --git a/app/soapbox/react-notification/index.js b/app/soapbox/react-notification/index.js new file mode 100644 index 000000000..3d7da7cee --- /dev/null +++ b/app/soapbox/react-notification/index.js @@ -0,0 +1,2 @@ +export { default as Notification } from './notification'; +export { default as NotificationStack } from './notificationStack'; diff --git a/app/soapbox/react-notification/notification.js b/app/soapbox/react-notification/notification.js new file mode 100644 index 000000000..ab1cddf9b --- /dev/null +++ b/app/soapbox/react-notification/notification.js @@ -0,0 +1,175 @@ +/* linting temp disabled while working on updates */ +/* eslint-disable */ +import React, { Component } from 'react'; +import defaultPropTypes from './defaultPropTypes'; + +class Notification extends Component { + constructor(props) { + super(props); + + this.getBarStyle = this.getBarStyle.bind(this); + this.getActionStyle = this.getActionStyle.bind(this); + this.getTitleStyle = this.getTitleStyle.bind(this); + this.handleClick = this.handleClick.bind(this); + + if (props.onDismiss && props.isActive) { + this.dismissTimeout = setTimeout( + props.onDismiss, + props.dismissAfter + ); + } + } + + componentWillReceiveProps(nextProps) { + if (nextProps.dismissAfter === false) return; + + // See http://eslint.org/docs/rules/no-prototype-builtins + if (!{}.hasOwnProperty.call(nextProps, 'isLast')) { + clearTimeout(this.dismissTimeout); + } + + if (nextProps.onDismiss) { + if ( + (nextProps.isActive && !this.props.isActive) || + (nextProps.dismissAfter && this.props.dismissAfter === false) + ) { + this.dismissTimeout = setTimeout( + nextProps.onDismiss, + nextProps.dismissAfter + ); + } + } + } + + componentWillUnmount() { + if (this.props.dismissAfter) clearTimeout(this.dismissTimeout); + } + + /* + * @description Dynamically get the styles for the bar. + * @returns {object} result The style. + */ + getBarStyle() { + if (this.props.style === false) return {}; + + const { isActive, barStyle, activeBarStyle } = this.props; + + const baseStyle = { + position: 'fixed', + bottom: '2rem', + left: '-100%', + width: 'auto', + padding: '1rem', + margin: 0, + color: '#fafafa', + font: '1rem normal Roboto, sans-serif', + borderRadius: '5px', + background: '#212121', + borderSizing: 'border-box', + boxShadow: '0 0 1px 1px rgba(10, 10, 11, .125)', + cursor: 'default', + WebKitTransition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)', + MozTransition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)', + msTransition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)', + OTransition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)', + transition: '.5s cubic-bezier(0.89, 0.01, 0.5, 1.1)', + WebkitTransform: 'translatez(0)', + MozTransform: 'translatez(0)', + msTransform: 'translatez(0)', + OTransform: 'translatez(0)', + transform: 'translatez(0)' + }; + + return isActive ? + Object.assign({}, baseStyle, { left: '1rem' }, barStyle, activeBarStyle) : + Object.assign({}, baseStyle, barStyle); + } + + /* + * @function getActionStyle + * @description Dynamically get the styles for the action text. + * @returns {object} result The style. + */ + getActionStyle() { + return this.props.style !== false ? Object.assign({}, { + padding: '0.125rem', + marginLeft: '1rem', + color: '#f44336', + font: '.75rem normal Roboto, sans-serif', + lineHeight: '1rem', + letterSpacing: '.125ex', + textTransform: 'uppercase', + borderRadius: '5px', + cursor: 'pointer' + }, this.props.actionStyle) : {}; + } + + /* + * @function getTitleStyle + * @description Dynamically get the styles for the title. + * @returns {object} result The style. + */ + getTitleStyle() { + return this.props.style !== false ? Object.assign({}, { + fontWeight: '700', + marginRight: '.5rem' + }, this.props.titleStyle) : {}; + } + + /* + * @function handleClick + * @description Handle click events on the action button. + */ + handleClick() { + if (this.props.onClick && typeof this.props.onClick === 'function') { + return this.props.onClick(); + } + } + + render() { + let className = 'notification-bar'; + + if (this.props.isActive) className += ` ${this.props.activeClassName}`; + if (this.props.className) className += ` ${this.props.className}`; + + return ( +
+
+ {this.props.title ? ( + + {this.props.title} + + ) : null} + + {/* eslint-disable */} + + {this.props.message} + + + {this.props.action ? ( + + {this.props.action} + + ) : null} +
+
+ ); + } +} + +Notification.propTypes = defaultPropTypes; + +Notification.defaultProps = { + isActive: false, + dismissAfter: 2000, + activeClassName: 'notification-bar-active' +}; + +export default Notification; diff --git a/app/soapbox/react-notification/notificationStack.js b/app/soapbox/react-notification/notificationStack.js new file mode 100644 index 000000000..dc9c2459b --- /dev/null +++ b/app/soapbox/react-notification/notificationStack.js @@ -0,0 +1,95 @@ +/* linting temp disabled while working on updates */ +/* eslint-disable */ +import React from 'react'; +import PropTypes from 'prop-types'; +import StackedNotification from './stackedNotification'; +import defaultPropTypes from './defaultPropTypes'; + +function defaultBarStyleFactory(index, style) { + return Object.assign( + {}, + style, + { bottom: `${2 + (index * 4)}rem` } + ); +} + +function defaultActionStyleFactory(index, style) { + return Object.assign( + {}, + style, + {} + ); +} + +/** +* The notification list does not have any state, so use a +* pure function here. It just needs to return the stacked array +* of notification components. +*/ +const NotificationStack = props => ( +
+ {props.notifications.map((notification, index) => { + const isLast = index === 0 && props.notifications.length === 1; + const dismissNow = isLast || !props.dismissInOrder; + + // Handle styles + const barStyle = props.barStyleFactory(index, notification.barStyle, notification); + const actionStyle = props.actionStyleFactory(index, notification.actionStyle, notification); + const activeBarStyle = props.activeBarStyleFactory( + index, + notification.activeBarStyle, + notification + ); + + // Allow onClick from notification stack or individual notifications + const onClick = notification.onClick || props.onClick; + const onDismiss = props.onDismiss; + + let { dismissAfter } = notification; + + if (dismissAfter !== false) { + if (dismissAfter == null) dismissAfter = props.dismissAfter; + if (!dismissNow) dismissAfter += index * 1000; + } + + return ( + + ); + })} +
+); + +/* eslint-disable react/no-unused-prop-types, react/forbid-prop-types */ +NotificationStack.propTypes = { + activeBarStyleFactory: PropTypes.func, + barStyleFactory: PropTypes.func, + actionStyleFactory: PropTypes.func, + dismissInOrder: PropTypes.bool, + notifications: PropTypes.array.isRequired, + onDismiss: PropTypes.func.isRequired, + onClick: PropTypes.func, + action: defaultPropTypes.action +}; + +NotificationStack.defaultProps = { + activeBarStyleFactory: defaultBarStyleFactory, + barStyleFactory: defaultBarStyleFactory, + actionStyleFactory: defaultActionStyleFactory, + dismissInOrder: true, + dismissAfter: 1000, + onClick: () => {} +}; +/* eslint-enable no-alert, no-console */ + +export default NotificationStack; diff --git a/app/soapbox/react-notification/stackedNotification.js b/app/soapbox/react-notification/stackedNotification.js new file mode 100644 index 000000000..c8d7200d4 --- /dev/null +++ b/app/soapbox/react-notification/stackedNotification.js @@ -0,0 +1,69 @@ +/* linting temp disabled while working on updates */ +/* eslint-disable */ +import React, { Component } from 'react'; +import defaultPropTypes from './defaultPropTypes'; +import Notification from './notification'; + +class StackedNotification extends Component { + constructor(props) { + super(props); + + this.state = { + isActive: false + }; + + this.handleClick = this.handleClick.bind(this); + } + + componentDidMount() { + this.activeTimeout = setTimeout(this.setState.bind(this, { + isActive: true + }), 1); + + this.dismiss(this.props.dismissAfter); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.dismissAfter !== this.props.dismissAfter) { + this.dismiss(nextProps.dismissAfter); + } + } + + componentWillUnmount() { + clearTimeout(this.activeTimeout); + clearTimeout(this.dismissTimeout); + } + + dismiss(dismissAfter) { + if (dismissAfter === false) return; + + this.dismissTimeout = setTimeout(this.setState.bind(this, { + isActive: false + }), dismissAfter); + } + + /* + * @function handleClick + * @description Bind deactivate Notification function to Notification click handler + */ + handleClick() { + if (this.props.onClick && typeof this.props.onClick === 'function') { + return this.props.onClick(this.setState.bind(this, { isActive: false })); + } + } + + render() { + return ( + setTimeout(this.props.onDismiss, 300)} + isActive={this.state.isActive} + /> + ); + } +} + +StackedNotification.propTypes = defaultPropTypes; + +export default StackedNotification; diff --git a/package.json b/package.json index 0918b337b..a34f1b539 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,6 @@ "react-inlinesvg": "^3.0.0", "react-intl": "^5.0.0", "react-motion": "^0.5.2", - "react-notification": "^6.8.5", "react-otp-input": "^2.4.0", "react-overlays": "^0.9.0", "react-popper": "^2.3.0", diff --git a/yarn.lock b/yarn.lock index b8d57fbff..4469a177a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9749,13 +9749,6 @@ react-motion@^0.5.2: prop-types "^15.5.8" raf "^3.1.0" -react-notification@^6.8.5: - version "6.8.5" - resolved "https://registry.yarnpkg.com/react-notification/-/react-notification-6.8.5.tgz#7ea90a633bb2a280d899e30c93cf372265cce4f0" - integrity sha512-3pJPhSsWNYizpyeMeWuC+jVthqE9WKqQ6rHq2naiiP4fLGN4irwL2Xp2Q8Qn7agW/e4BIDxarab6fJOUp1cKUw== - dependencies: - prop-types "^15.6.2" - react-onclickoutside@^6.12.0: version "6.12.1" resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.12.1.tgz#92dddd28f55e483a1838c5c2930e051168c1e96b"