diff --git a/.eslintrc.js b/.eslintrc.js index 9a92e50a8..d885cbeea 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -141,6 +141,7 @@ module.exports = { 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], 'react/jsx-indent': ['error', 2], // 'react/jsx-no-bind': ['error'], + 'react/jsx-no-comment-textnodes': 'error', 'react/jsx-no-duplicate-props': 'error', 'react/jsx-no-undef': 'error', 'react/jsx-tag-spacing': 'error', diff --git a/app/soapbox/actions/modals.js b/app/soapbox/actions/modals.ts similarity index 59% rename from app/soapbox/actions/modals.js rename to app/soapbox/actions/modals.ts index 72604ecc6..9d6e85139 100644 --- a/app/soapbox/actions/modals.js +++ b/app/soapbox/actions/modals.ts @@ -1,7 +1,8 @@ export const MODAL_OPEN = 'MODAL_OPEN'; export const MODAL_CLOSE = 'MODAL_CLOSE'; -export function openModal(type, props) { +/** Open a modal of the given type */ +export function openModal(type: string, props?: any) { return { type: MODAL_OPEN, modalType: type, @@ -9,7 +10,8 @@ export function openModal(type, props) { }; } -export function closeModal(type) { +/** Close the modal */ +export function closeModal(type: string) { return { type: MODAL_CLOSE, modalType: type, diff --git a/app/soapbox/components/emoji-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx new file mode 100644 index 000000000..32159b329 --- /dev/null +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -0,0 +1,117 @@ +import classNames from 'classnames'; +import React, { useState, useRef } from 'react'; +import { usePopper } from 'react-popper'; +import { useDispatch } from 'react-redux'; + +import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; +import { openModal } from 'soapbox/actions/modals'; +import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector'; +import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks'; +import { isUserTouching } from 'soapbox/is_mobile'; +import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; + +interface IEmojiButtonWrapper { + statusId: string, + children: JSX.Element, +} + +/** Provides emoji reaction functionality to the underlying button component */ +const EmojiButtonWrapper: React.FC = ({ statusId, children }): JSX.Element | null => { + const dispatch = useDispatch(); + const ownAccount = useOwnAccount(); + const status = useAppSelector(state => state.statuses.get(statusId)); + const soapboxConfig = useSoapboxConfig(); + + const [visible, setVisible] = useState(false); + // const [focused, setFocused] = useState(false); + + const ref = useRef(null); + const popperRef = useRef(null); + + const { styles, attributes } = usePopper(ref.current, popperRef.current, { + placement: 'top-start', + strategy: 'fixed', + modifiers: [ + { + name: 'offset', + options: { + offset: [-10, 0], + }, + }, + ], + }); + + if (!status) return null; + + const handleMouseEnter = () => { + setVisible(true); + }; + + const handleMouseLeave = () => { + setVisible(false); + }; + + const handleReact = (emoji: string): void => { + if (ownAccount) { + dispatch(simpleEmojiReact(status, emoji)); + } else { + dispatch(openModal('UNAUTHORIZED', { + action: 'FAVOURITE', + ap_id: status.url, + })); + } + + setVisible(false); + }; + + const handleClick: React.EventHandler = e => { + const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍'; + + if (isUserTouching()) { + if (visible) { + handleReact(meEmojiReact); + } else { + setVisible(true); + } + } else { + handleReact(meEmojiReact); + } + + e.stopPropagation(); + }; + + // const handleUnfocus: React.EventHandler = () => { + // setFocused(false); + // }; + + const selector = ( +
+ +
+ ); + + return ( +
+ {React.cloneElement(children, { + onClick: handleClick, + ref, + })} + + {selector} +
+ ); +}; + +export default EmojiButtonWrapper; diff --git a/app/soapbox/components/hoverable.tsx b/app/soapbox/components/hoverable.tsx deleted file mode 100644 index 751c413c1..000000000 --- a/app/soapbox/components/hoverable.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import classNames from 'classnames'; -import React, { useState, useRef } from 'react'; -import { usePopper } from 'react-popper'; - -interface IHoverable { - component: JSX.Element, -} - -/** Wrapper to render a given component when hovered */ -const Hoverable: React.FC = ({ - component, - children, -}): JSX.Element => { - - const [portalActive, setPortalActive] = useState(false); - - const ref = useRef(null); - const popperRef = useRef(null); - - const handleMouseEnter = () => { - setPortalActive(true); - }; - - const handleMouseLeave = () => { - setPortalActive(false); - }; - - const { styles, attributes } = usePopper(ref.current, popperRef.current, { - placement: 'top-start', - strategy: 'fixed', - modifiers: [ - { - name: 'offset', - options: { - offset: [-10, 0], - }, - }, - ], - }); - - return ( -
- {children} - -
- {component} -
-
- ); -}; - -export default Hoverable; diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 9011bec71..41c873c7c 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -6,8 +6,7 @@ import { connect } from 'react-redux'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; -import EmojiSelector from 'soapbox/components/emoji_selector'; -import Hoverable from 'soapbox/components/hoverable'; +import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import StatusActionButton from 'soapbox/components/status-action-button'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import { isUserTouching } from 'soapbox/is_mobile'; @@ -554,7 +553,7 @@ class StatusActionBar extends ImmutablePureComponent - )} - > + - + ): ( = ({ emoji, className, onClick, tabInd }; interface IEmojiSelector { - emojis: string[], + emojis: Iterable, onReact: (emoji: string) => void, visible?: boolean, focused?: boolean, @@ -40,7 +40,7 @@ const EmojiSelector: React.FC = ({ emojis, onReact, visible = fa space={2} className={classNames('bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max')} > - {emojis.map((emoji, i) => ( + {Array.from(emojis).map((emoji, i) => ( = ({ src, alt, size = 24, className }): JSX.El loader={loader} data-testid='svg-icon' > - /* If the fetch fails, fall back to displaying the loader */ + {/* If the fetch fails, fall back to displaying the loader */} {loader} ); diff --git a/app/soapbox/features/status/components/action-bar.tsx b/app/soapbox/features/status/components/action-bar.tsx index 1e5efa765..9ac9b007f 100644 --- a/app/soapbox/features/status/components/action-bar.tsx +++ b/app/soapbox/features/status/components/action-bar.tsx @@ -4,6 +4,7 @@ import { defineMessages, injectIntl, WrappedComponentProps as IntlComponentProps import { connect } from 'react-redux'; import { withRouter, RouteComponentProps } from 'react-router-dom'; +import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import { isUserTouching } from 'soapbox/is_mobile'; import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; import { getFeatures } from 'soapbox/utils/features'; @@ -574,19 +575,36 @@ class ActionBar extends React.PureComponent { {reblogButton} - + {features.emojiReacts ? ( + + + + ) : ( + + )} {canShare && ( - // NOTE: we cannot nest routes in a fragment - // https://stackoverflow.com/a/68637108 + {/* + NOTE: we cannot nest routes in a fragment + https://stackoverflow.com/a/68637108 + */} {features.federating && } {features.federating && } {features.federating && }