diff --git a/package-lock.json b/package-lock.json index f7c2ad5..6e1124b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -236,6 +236,12 @@ } } }, + "@blakeembrey/deque": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.4.tgz", + "integrity": "sha512-gzAiePolEXlMlQxbHWcdmz+b+PEixO5so0mU8jMlCemp8sxUWko9AzWc4EK3MV6BCUYd9cjq+Gd477cZiq5Kfw==", + "dev": true + }, "@commitlint/config-conventional": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-7.1.2.tgz", @@ -11202,6 +11208,42 @@ "wrappy": "1" } }, + "onchange": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/onchange/-/onchange-5.2.0.tgz", + "integrity": "sha512-kBNMF4KU1m0GkZCANckQZs3N41esf950T/gv7JIjNS6qWS8R34+iCKk/wmVRPEdaYCA+yi2aK2vNXS0RaB/V2A==", + "dev": true, + "requires": { + "@blakeembrey/deque": "^1.0.3", + "arrify": "^1.0.1", + "chokidar": "^2.0.0", + "cross-spawn": "^6.0.0", + "minimist": "^1.2.0", + "supports-color": "^5.5.0", + "tree-kill": "^1.2.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + } + } + }, "onecolor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/onecolor/-/onecolor-3.1.0.tgz", @@ -15205,6 +15247,15 @@ "scheduler": "^0.11.0" } }, + "react-animate-height": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/react-animate-height/-/react-animate-height-2.0.7.tgz", + "integrity": "sha512-NbdKlopeFDUY7oDlLV5T3XvpV/yi8sqO7b78mzObpgWr+prPJ/tipKYNGTDHetjElnmrC5dyC5vHUu86ua3G1A==", + "requires": { + "classnames": "^2.2.5", + "prop-types": "^15.6.1" + } + }, "react-color": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.14.1.tgz", @@ -15506,9 +15557,9 @@ } }, "react-transition-group": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.5.0.tgz", - "integrity": "sha512-qYB3JBF+9Y4sE4/Mg/9O6WFpdoYjeeYqx0AFb64PTazVy8RPMiE3A47CG9QmM4WJ/mzDiZYslV+Uly6O1Erlgw==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.5.3.tgz", + "integrity": "sha512-2DGFck6h99kLNr8pOFk+z4Soq3iISydwOFeeEVPjTN6+Y01CmvbWmnN02VuTWyFdnRtIDPe+wy2q6Ui8snBPZg==", "requires": { "dom-helpers": "^3.3.1", "loose-envify": "^1.4.0", @@ -17725,6 +17776,12 @@ "dev": true, "optional": true }, + "tree-kill": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.1.tgz", + "integrity": "sha512-4hjqbObwlh2dLyW4tcz0Ymw0ggoaVDMveUB9w8kFSQScdRLo0gxO9J7WFcUBo+W3C1TLdFIEwNOWebgZZ0RH9Q==", + "dev": true + }, "trim": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", diff --git a/package.json b/package.json index 8897367..8b89df2 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "test": "exit 0", "lint": "eslint --ignore-path .gitignore . && stylelint --ignore-path .gitignore \"**/*.css\"", "prerelease": "npm t && npm run lint && npm run build", - "release": "standard-version" + "release": "standard-version", + "dev": "onchange src/** -- npm run build" }, "dependencies": { "@moxy/grow-element-fn": "^1.0.6", @@ -50,11 +51,12 @@ "prop-type-conditionals": "0.0.6", "prop-types": "^15.6.2", "proper-on-transition-end": "^0.3.0", + "react-animate-height": "^2.0.7", "react-modal": "^3.5.1", "react-popper": "^1.0.0", "react-preload-image": "^1.0.4", "react-time-ago": "^3.0.2", - "react-transition-group": "^2.4.0" + "react-transition-group": "^2.5.3" }, "peerDepedencies": { "react": "^16.6.0", @@ -75,6 +77,7 @@ "husky": "^1.0.0", "lint-staged": "^8.0.0", "marked": "^0.5.1", + "onchange": "^5.2.0", "postcss-cli": "^6.0.0", "postcss-preset-moxy": "^2.3.0", "react": "^16.6.0", diff --git a/src/components/comment-input/CommentInput.js b/src/components/comment-input/CommentInput.js index f936e2c..63680b5 100644 --- a/src/components/comment-input/CommentInput.js +++ b/src/components/comment-input/CommentInput.js @@ -7,7 +7,6 @@ import TextareaAutosize from '../textarea-autosize'; import TextButton from '../text-button'; import { ModalTrigger, ConfirmModal } from '../modal'; import { Author } from '../comment-common'; -import FocusManager from './FocusManager'; import styles from './CommentInput.css'; const isBodyEmpty = (body) => !body || isWhitespace(body); @@ -33,18 +32,13 @@ export default class CommentInput extends PureComponent { type: PropTypes.oneOf(['reply', 'edit']), author: PropTypes.object.isRequired, body: PropTypes.string, - focusOnMount: PropTypes.bool, preloadAvatarImage: PropTypes.bool, onSubmit: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, className: PropTypes.string, + rows: PropTypes.number, }; - static defaultProps = { - focusOnMount: true, - }; - - focusManagerRef = createRef(); textareaAutosizeRef = createRef(); constructor(props) { @@ -57,76 +51,66 @@ export default class CommentInput extends PureComponent { } render() { - const { type, body, author, focusOnMount, preloadAvatarImage, onSubmit, onCancel, className, ...rest } = this.props; + const { type, body, author, preloadAvatarImage, onSubmit, onCancel, className, ...rest } = this.props; const { empty, changed } = this.state; const reply = type === 'reply'; const ConfirmCancelModal = reply ? ConfirmCancelReplyModal : ConfirmCancelEditModal; return ( - -
- - -
- - -
- - { reply ? 'Send' : 'Save' } - - - - - { changed ? ( - }> - - Cancel - - - ) : ( +
+ + +
+ + +
+ + { reply ? 'Send' : 'Save' } + + + + + { changed ? ( + }> - Cancel + Cancel - ) } -
+ + ) : ( + + Cancel + + ) }
- +
); } - isFocused() { - return this.focusManagerRef.current ? this.focusManagerRef.current.isFocused() : false; - } - - focus() { - this.focusManagerRef.current && this.focusManagerRef.current.focus(); - } - handleActionMouseDown = (event) => { // Since the textarea animates, when the mouseup happens the mouse is no longer within the button // Because of that we trigger click() manually after the animation ends diff --git a/src/components/comment-input/FocusManager.js b/src/components/comment-input/FocusManager.js deleted file mode 100644 index 170690e..0000000 --- a/src/components/comment-input/FocusManager.js +++ /dev/null @@ -1,111 +0,0 @@ -import React, { Component } from 'react'; -import { findDOMNode } from 'react-dom'; -import PropTypes from 'prop-types'; -import Observer from '@researchgate/react-intersection-observer'; - -const FOCUS_WHEN_IN_VIEW_MAX_WAIT = 10000; - -export default class FocusManager extends Component { - static propTypes = { - focusOnMount: PropTypes.bool, - children: PropTypes.element.isRequired, - }; - - state = { - inView: false, - }; - - componentDidMount() { - if (this.props.focusOnMount) { - this.focus(); - } - } - - componentWillUnmount() { - clearTimeout(this.focusTimeout); - } - - render() { - const { children } = this.props; - - return ( - - { children } - - ); - } - - isFocused() { - return document.activeElement && document.activeElement === this.getTextareaNode(); - } - - focus() { - const { inView } = this.state; - - if (!inView) { - this.markToFocusWhenInView(); - this.scrollIntoView(); - } else { - this.cancelFocusWhenInView(); - this.focusTextarea(); - } - } - - getTextareaNode() { - const node = findDOMNode(this); - const textareaNode = node && node.querySelector('textarea'); - - return textareaNode; - } - - scrollIntoView() { - const node = findDOMNode(this); - - if (node) { - node.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }); - } - } - - focusTextarea() { - const textareaNode = this.getTextareaNode(); - - if (textareaNode) { - textareaNode.focus(); - } - } - - shouldFocusWhenInView() { - return !!this.resetFocusWhenInViewTimeout; - } - - markToFocusWhenInView() { - clearTimeout(this.resetFocusWhenInViewTimeout); - this.resetFocusWhenInViewTimeout = setTimeout(() => { - this.resetFocusWhenInViewTimeout = null; - }, FOCUS_WHEN_IN_VIEW_MAX_WAIT); - } - - cancelFocusWhenInView() { - clearTimeout(this.resetFocusWhenInViewTimeout); - this.resetFocusWhenInViewTimeout = null; - } - - handleObserverChange = ({ isIntersecting: inView }) => { - this.setState({ inView }); - - if (inView) { - if (this.shouldFocusWhenInView()) { - this.cancelFocusWhenInView(); - this.focusTextarea(); - } - // Keep the textarea in the viewport if it's focused - } else if (this.isFocused()) { - this.scrollIntoView(); - } - }; -} diff --git a/src/components/comment-placer/CommentPlacer.js b/src/components/comment-placer/CommentPlacer.js new file mode 100644 index 0000000..a990478 --- /dev/null +++ b/src/components/comment-placer/CommentPlacer.js @@ -0,0 +1,187 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { findDOMNode } from 'react-dom'; +import FocusManager from './focus-manager'; +import FadeAndGrowTransition from './fade-and-grow-transition'; +import FadeTransition from './fade-transition'; + +export default class CommentPlacer extends Component { + static propTypes = { + animateOnMount: PropTypes.bool, + animateOnUnmount: PropTypes.bool, + scrollOnMount: PropTypes.bool, + autofocus: PropTypes.bool, + children: PropTypes.element.isRequired, + animation: PropTypes.oneOf(['fade', 'fade-and-grow']).isRequired, + className: PropTypes.string, + listHasScroll: PropTypes.bool.isRequired, + }; + + static defaultProps = { + animateOnMount: false, + animateOnUnmount: false, + scrollOnMount: false, + autofocus: false, + }; + + static getDerivedStateFromProps(props, state) { + if (props.children) { + // Store children on state object if children passed as prop is defined + return { + animation: props.animation, + children: props.children, + }; + } else if (state.children) { + if (props.animation !== state.animation) { + return { + animation: props.animation, + animationHasChanged: true, + }; + } + + // If children passed as prop is not defined and the previous state has stored children + // it means the component was already mounted and we must unmount it + return { + triggerUnmount: true, + }; + } + + return null; + } + + state = { + animation: null, + children: null, + triggerUnmount: false, + mountAnimationCompleted: false, + animationHasChanged: false, + }; + + getSnapshotBeforeUpdate() { + if (this.state.animationHasChanged) { + this.textareaValue = this.getTextareaValue(); + } + + return null; + } + + componentDidUpdate() { + const { animationHasChanged, triggerUnmount } = this.state; + + // Reset animationHasChanged so that component can unmount normally + if (animationHasChanged && !this.props.children && !triggerUnmount) { + this.setTextareaValue(this.textareaValue); + this.setState({ + animationHasChanged: false, + }); + } + } + + render() { + const { autofocus, className } = this.props; + const { children, mountAnimationCompleted } = this.state; + + return ( + + { children && ( + + { this.renderAnimationComponent() } + + ) } + + ); + } + + renderAnimationComponent = () => { + const { animation } = this.props; + + return animation === 'fade-and-grow' ? + this.renderFadeAndGrow() : + this.renderFade(); + }; + + renderFadeAndGrow = () => { + const { animateOnMount, animateOnUnmount, listHasScroll } = this.props; + const { triggerUnmount, children } = this.state; + + return ( + (inView) => ( + + { children } + + ) + ); + }; + + renderFade = () => { + const { triggerUnmount, children, animationHasChanged } = this.state; + const { animateOnMount, animateOnUnmount } = this.props; + + return ( + + { children } + + ); + }; + + resetState = () => { + this.setState({ + animation: null, + children: null, + triggerUnmount: false, + mountAnimationCompleted: false, + animationHasChanged: false, + }); + }; + + getTextareaValue = () => { + const textarea = findDOMNode(this).querySelector('textarea'); + + return textarea && textarea.value; + }; + + setTextareaValue = (value) => { + const textarea = findDOMNode(this).querySelector('textarea'); + + if (textarea) { + textarea.value = value; + } + }; + + scrollIntoView = (elem) => { + const node = elem ? elem : findDOMNode(this); + + if (node) { + node.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + } + }; + + handleAnimationEnd = (isUnmounting) => { + // When animation-out ends, state object must be reset to its initial value + // When animation-in ends, a scroll into view must happen if needed + if (isUnmounting) { + this.resetState(); + } else { + this.setState({ mountAnimationCompleted: true }, () => { + if (this.props.scrollOnMount) { + this.scrollIntoView(); + } + }); + } + }; +} diff --git a/src/components/comment-placer/fade-and-grow-transition/FadeAndGrowTransition.js b/src/components/comment-placer/fade-and-grow-transition/FadeAndGrowTransition.js new file mode 100644 index 0000000..f1fe332 --- /dev/null +++ b/src/components/comment-placer/fade-and-grow-transition/FadeAndGrowTransition.js @@ -0,0 +1,87 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import AnimateHeight from 'react-animate-height'; + +export default class FadeAndGrowTransition extends Component { + static propTypes = { + children: PropTypes.node.isRequired, + duration: PropTypes.number, + animateOnMount: PropTypes.bool, + animateOnUnmount: PropTypes.bool, + triggerUnmount: PropTypes.bool, + onAnimationEnd: PropTypes.func, + }; + + static defaultProps = { + duration: 300, + }; + + static getDerivedStateFromProps(props, state) { + if (!state.isMounted) { + if (props.animateOnMount) { + return { + willAnimate: true, + }; + } + + return { + willAnimate: false, + }; + } + if (props.triggerUnmount) { + return { + willAnimate: props.animateOnUnmount, + }; + } + + return null; + } + + state = { + willAnimate: undefined, + isMounted: false, + }; + + componentDidMount() { + this.setState({ + willAnimate: false, + isMounted: true, + }); + } + + componentDidUpdate = (_, prevState) => { + // Simulate animationEnd to scroll to element when animationOnMmount is set to 'false' + if ((this.state.isMounted !== prevState.isMounted) && !this.props.animateOnMount) { + this.simulateAnimationEnd(false); + } + // Simulate animationEnd to reset parent state when animationOnUnmount is set to 'false' + if (this.props.triggerUnmount && !this.state.willAnimate) { + this.simulateAnimationEnd(true); + } + }; + + render() { + const { children, duration } = this.props; + const { willAnimate } = this.state; + + return ( + + { children } + + ); + } + + simulateAnimationEnd = (isUnmounting) => { + this.props.onAnimationEnd(isUnmounting); + }; + + handleAnimationEnd = (element) => { + const isUnmounting = element.newHeight === 0; + + this.props.onAnimationEnd(isUnmounting); + }; +} diff --git a/src/components/comment-placer/fade-and-grow-transition/index.js b/src/components/comment-placer/fade-and-grow-transition/index.js new file mode 100644 index 0000000..2b2b05e --- /dev/null +++ b/src/components/comment-placer/fade-and-grow-transition/index.js @@ -0,0 +1 @@ +export { default } from './FadeAndGrowTransition'; diff --git a/src/components/comment-placer/fade-transition/FadeTransition.css b/src/components/comment-placer/fade-transition/FadeTransition.css new file mode 100644 index 0000000..355cd0b --- /dev/null +++ b/src/components/comment-placer/fade-transition/FadeTransition.css @@ -0,0 +1,17 @@ +.enter { + opacity: 0.01; +} + +.enterActive { + opacity: 1; + transition: opacity 0.3s ease-out; +} + +.exit { + opacity: 1; +} + +.exitActive { + opacity: 0.01; + transition: opacity 0.3s ease-out; +} diff --git a/src/components/comment-placer/fade-transition/FadeTransition.js b/src/components/comment-placer/fade-transition/FadeTransition.js new file mode 100644 index 0000000..bda4d20 --- /dev/null +++ b/src/components/comment-placer/fade-transition/FadeTransition.js @@ -0,0 +1,54 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { CSSTransition } from 'react-transition-group'; + +import styles from './FadeTransition.css'; + +export default class FadeTransition extends Component { + static propTypes = { + children: PropTypes.element.isRequired, + duration: PropTypes.number, + animateOnMount: PropTypes.bool, + animateOnUnmount: PropTypes.bool, + triggerUnmount: PropTypes.bool, + onAnimationEnd: PropTypes.func, + }; + + static defaultProps = { + duration: 300, + }; + + static classes = { + appear: styles.enter, + appearActive: styles.enterActive, + enter: styles.enter, + enterActive: styles.enterActive, + enterDone: styles.enterDone, + exit: styles.exit, + exitActive: styles.exitActive, + }; + + render() { + const { children, duration, animateOnMount, triggerUnmount } = this.props; + + return ( + + { children } + + ); + } + + handleOnEntered = () => this.props.onAnimationEnd(false); + + handleOnExit = () => !this.props.animateOnUnmount && this.props.onAnimationEnd(true); + + handleOnExited = () => this.props.onAnimationEnd(true); +} diff --git a/src/components/comment-placer/fade-transition/index.js b/src/components/comment-placer/fade-transition/index.js new file mode 100644 index 0000000..63b6124 --- /dev/null +++ b/src/components/comment-placer/fade-transition/index.js @@ -0,0 +1 @@ +export { default } from './FadeTransition'; diff --git a/src/components/comment-placer/focus-manager/FocusManager.css b/src/components/comment-placer/focus-manager/FocusManager.css new file mode 100644 index 0000000..745cdd0 --- /dev/null +++ b/src/components/comment-placer/focus-manager/FocusManager.css @@ -0,0 +1,8 @@ +.container { + min-height: 1px; + transition: transform 0.4s cubic-bezier(0.215, 0.61, 0.355, 1); + + &.scaledDown { + transform: scale(0.95); + } +} diff --git a/src/components/comment-placer/focus-manager/FocusManager.js b/src/components/comment-placer/focus-manager/FocusManager.js new file mode 100644 index 0000000..c0a1266 --- /dev/null +++ b/src/components/comment-placer/focus-manager/FocusManager.js @@ -0,0 +1,99 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { findDOMNode } from 'react-dom'; +import Observer from '@researchgate/react-intersection-observer'; + +import styles from './FocusManager.css'; + +export default class FocusManager extends Component { + static propTypes = { + children: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.node, + ]).isRequired, + scrollIntoView: PropTypes.func.isRequired, + mountAnimationCompleted: PropTypes.bool, + autofocus: PropTypes.bool, + className: PropTypes.string, + }; + + state = { + totallyInView: undefined, + inView: true, + }; + + componentDidMount() { + this.shouldFocusAfterAnimation = true; + } + + componentDidUpdate() { + const mustFocusAfterAnimation = this.props.mountAnimationCompleted && this.state.totallyInView && this.shouldFocusAfterAnimation; + + this.focusTimeout = mustFocusAfterAnimation ? + setTimeout(this.focus, 100) : + null; + } + + componentWillUnmount() { + clearTimeout(this.focusTimeout); + } + + render() { + const { children, className } = this.props; + const { totallyInView, inView } = this.state; + const containerClasses = classNames(styles.container, { + [styles.scaledDown]: !inView, + }); + const isChildrenFunction = typeof children === 'function'; + + return ( + +
+ { typeof totallyInView !== 'undefined' && ( + isChildrenFunction ? children(totallyInView) : children + ) } +
+
+ ); + } + + isFocused = () => (document.activeElement && document.activeElement === this.getTextareaNode()); + + focus = () => { + this.shouldFocusAfterAnimation = false; + this.focusTextarea(); + }; + + getTextareaNode() { + const node = findDOMNode(this); + const textareaNode = node && node.querySelector('textarea'); + + return textareaNode; + } + + focusTextarea() { + const textareaNode = this.getTextareaNode(); + + if (textareaNode) { + textareaNode.focus(); + } + } + + scrollIntoViewWhenFocused = () => { + if (!this.state.totallyInView && this.isFocused() && this.props.autofocus) { + this.props.scrollIntoView(); + } + }; + + handleObserverChange = ({ isIntersecting, intersectionRatio }) => { + this.setState({ + totallyInView: isIntersecting && intersectionRatio >= 1, + inView: isIntersecting, + }, () => { + this.scrollIntoViewWhenFocused(); + }); + }; +} diff --git a/src/components/comment-placer/focus-manager/index.js b/src/components/comment-placer/focus-manager/index.js new file mode 100644 index 0000000..4b368a0 --- /dev/null +++ b/src/components/comment-placer/focus-manager/index.js @@ -0,0 +1 @@ +export { default } from './FocusManager'; diff --git a/src/components/comment-placer/index.js b/src/components/comment-placer/index.js new file mode 100644 index 0000000..6fe5097 --- /dev/null +++ b/src/components/comment-placer/index.js @@ -0,0 +1 @@ +export { default } from './CommentPlacer'; diff --git a/src/components/textarea-autosize/TextareaAutosize.js b/src/components/textarea-autosize/TextareaAutosize.js index 10915f2..ed16ecd 100644 --- a/src/components/textarea-autosize/TextareaAutosize.js +++ b/src/components/textarea-autosize/TextareaAutosize.js @@ -1,5 +1,6 @@ import React, { Component, createRef } from 'react'; import PropTypes from 'prop-types'; +import { findDOMNode } from 'react-dom'; import classNames from 'classnames'; import { isRequiredIf } from 'prop-type-conditionals'; import growElementFn from '@moxy/grow-element-fn'; @@ -75,6 +76,7 @@ export default class TextareaAutosize extends Component { // Create new comments when pressing enter without shift if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); + findDOMNode(this).blur(); this.props.onSubmit(); } }; diff --git a/src/index.js b/src/index.js index 41d0361..be81aa0 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ export { default as CircularLoader } from './components/circular-loader'; export { default as Comment } from './components/comment'; export { default as CommentError } from './components/comment-error'; export { default as CommentInput } from './components/comment-input'; +export { default as CommentPlacer } from './components/comment-placer'; export { default as CommentPlaceholder } from './components/comment-placeholder'; export { default as CommentFrame } from './components/comment-frame'; export { default as DiscussionFab } from './components/discussion-fab';