From 902aab1245a06319b7acd20045bd9ce051627300 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 28 Mar 2025 13:34:51 +0100 Subject: [PATCH] Remove react-motion library (#34293) --- .../mastodon/components/animated_number.tsx | 78 ++-- app/javascript/mastodon/components/poll.jsx | 248 ------------ app/javascript/mastodon/components/poll.tsx | 352 ++++++++++++++++++ .../mastodon/components/status_content.jsx | 4 +- .../mastodon/containers/media_container.jsx | 2 +- .../mastodon/containers/poll_container.js | 38 -- .../compose/components/compose_form.jsx | 4 +- .../compose/components/upload_progress.jsx | 48 --- .../compose/components/upload_progress.tsx | 61 +++ .../features/compose/components/warning.jsx | 28 -- .../features/compose/components/warning.tsx | 96 +++++ .../compose/containers/warning_container.jsx | 46 --- .../components/announcements.jsx | 119 +++--- .../features/ui/components/upload_area.jsx | 55 --- .../features/ui/components/upload_area.tsx | 78 ++++ app/javascript/mastodon/features/ui/index.jsx | 2 +- .../features/ui/util/optional_motion.js | 7 - .../features/ui/util/reduced_motion.jsx | 45 --- package.json | 2 - yarn.lock | 49 +-- 20 files changed, 692 insertions(+), 670 deletions(-) delete mode 100644 app/javascript/mastodon/components/poll.jsx create mode 100644 app/javascript/mastodon/components/poll.tsx delete mode 100644 app/javascript/mastodon/containers/poll_container.js delete mode 100644 app/javascript/mastodon/features/compose/components/upload_progress.jsx create mode 100644 app/javascript/mastodon/features/compose/components/upload_progress.tsx delete mode 100644 app/javascript/mastodon/features/compose/components/warning.jsx create mode 100644 app/javascript/mastodon/features/compose/components/warning.tsx delete mode 100644 app/javascript/mastodon/features/compose/containers/warning_container.jsx delete mode 100644 app/javascript/mastodon/features/ui/components/upload_area.jsx create mode 100644 app/javascript/mastodon/features/ui/components/upload_area.tsx delete mode 100644 app/javascript/mastodon/features/ui/util/optional_motion.js delete mode 100644 app/javascript/mastodon/features/ui/util/reduced_motion.jsx diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx index 6c1e0aaec14..db422f47ce0 100644 --- a/app/javascript/mastodon/components/animated_number.tsx +++ b/app/javascript/mastodon/components/animated_number.tsx @@ -1,6 +1,6 @@ -import { useCallback, useState } from 'react'; +import { useEffect, useState } from 'react'; -import { TransitionMotion, spring } from 'react-motion'; +import { animated, useSpring, config } from '@react-spring/web'; import { reduceMotion } from '../initial_state'; @@ -11,53 +11,49 @@ interface Props { } export const AnimatedNumber: React.FC = ({ value }) => { const [previousValue, setPreviousValue] = useState(value); - const [direction, setDirection] = useState<1 | -1>(1); + const direction = value > previousValue ? -1 : 1; - if (previousValue !== value) { - setPreviousValue(value); - setDirection(value > previousValue ? 1 : -1); - } - - const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]); - const willLeave = useCallback( - () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), - [direction], + const [styles, api] = useSpring( + () => ({ + from: { transform: `translateY(${100 * direction}%)` }, + to: { transform: 'translateY(0%)' }, + onRest() { + setPreviousValue(value); + }, + config: { ...config.gentle, duration: 200 }, + immediate: true, // This ensures that the animation is not played when the component is first rendered + }), + [value, previousValue], ); + // When the value changes, start the animation + useEffect(() => { + if (value !== previousValue) { + void api.start({ reset: true }); + } + }, [api, previousValue, value]); + if (reduceMotion) { return ; } - const styles = [ - { - key: `${value}`, - data: value, - style: { y: spring(0, { damping: 35, stiffness: 400 }) }, - }, - ]; - return ( - - {(items) => ( - - {items.map(({ key, data, style }) => ( - 0 ? 'absolute' : 'static', - transform: `translateY(${(style.y ?? 0) * 100}%)`, - }} - > - - - ))} - + + + + + {value !== previousValue && ( + + + )} - + ); }; diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx deleted file mode 100644 index 13261310090..00000000000 --- a/app/javascript/mastodon/components/poll.jsx +++ /dev/null @@ -1,248 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -import escapeTextContentForBrowser from 'escape-html'; -import spring from 'react-motion/lib/spring'; - -import CheckIcon from '@/material-icons/400-24px/check.svg?react'; -import { Icon } from 'mastodon/components/icon'; -import emojify from 'mastodon/features/emoji/emoji'; -import Motion from 'mastodon/features/ui/util/optional_motion'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; - -import { RelativeTimestamp } from './relative_timestamp'; - -const messages = defineMessages({ - closed: { - id: 'poll.closed', - defaultMessage: 'Closed', - }, - voted: { - id: 'poll.voted', - defaultMessage: 'You voted for this answer', - }, - votes: { - id: 'poll.votes', - defaultMessage: '{votes, plural, one {# vote} other {# votes}}', - }, -}); - -class Poll extends ImmutablePureComponent { - static propTypes = { - identity: identityContextPropShape, - poll: ImmutablePropTypes.record.isRequired, - status: ImmutablePropTypes.map.isRequired, - lang: PropTypes.string, - intl: PropTypes.object.isRequired, - disabled: PropTypes.bool, - refresh: PropTypes.func, - onVote: PropTypes.func, - onInteractionModal: PropTypes.func, - }; - - state = { - selected: {}, - expired: null, - }; - - static getDerivedStateFromProps (props, state) { - const { poll } = props; - const expires_at = poll.get('expires_at'); - const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.now(); - return (expired === state.expired) ? null : { expired }; - } - - componentDidMount () { - this._setupTimer(); - } - - componentDidUpdate () { - this._setupTimer(); - } - - componentWillUnmount () { - clearTimeout(this._timer); - } - - _setupTimer () { - const { poll } = this.props; - clearTimeout(this._timer); - if (!this.state.expired) { - const delay = (new Date(poll.get('expires_at'))).getTime() - Date.now(); - this._timer = setTimeout(() => { - this.setState({ expired: true }); - }, delay); - } - } - - _toggleOption = value => { - if (this.props.poll.get('multiple')) { - const tmp = { ...this.state.selected }; - if (tmp[value]) { - delete tmp[value]; - } else { - tmp[value] = true; - } - this.setState({ selected: tmp }); - } else { - const tmp = {}; - tmp[value] = true; - this.setState({ selected: tmp }); - } - }; - - handleOptionChange = ({ target: { value } }) => { - this._toggleOption(value); - }; - - handleOptionKeyPress = (e) => { - if (e.key === 'Enter' || e.key === ' ') { - this._toggleOption(e.target.getAttribute('data-index')); - e.stopPropagation(); - e.preventDefault(); - } - }; - - handleVote = () => { - if (this.props.disabled) { - return; - } - - if (this.props.identity.signedIn) { - this.props.onVote(Object.keys(this.state.selected)); - } else { - this.props.onInteractionModal('vote', this.props.status); - } - }; - - handleRefresh = () => { - if (this.props.disabled) { - return; - } - - this.props.refresh(); - }; - - handleReveal = () => { - this.setState({ revealed: true }); - }; - - renderOption (option, optionIndex, showResults) { - const { poll, lang, disabled, intl } = this.props; - const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); - const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100; - const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count')); - const active = !!this.state.selected[`${optionIndex}`]; - const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); - - const title = option.getIn(['translation', 'title']) || option.get('title'); - let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); - - if (!titleHtml) { - const emojiMap = emojiMap(poll); - titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); - } - - return ( -
  • - - - {showResults && ( - - {({ width }) => - - } - - )} -
  • - ); - } - - render () { - const { poll, intl } = this.props; - const { revealed, expired } = this.state; - - if (!poll) { - return null; - } - - const timeRemaining = expired ? intl.formatMessage(messages.closed) : ; - const showResults = poll.get('voted') || revealed || expired; - const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); - - let votesCount = null; - - if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) { - votesCount = ; - } else { - votesCount = ; - } - - return ( -
    -
      - {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))} -
    - -
    - {!showResults && } - {!showResults && <> · } - {showResults && !this.props.disabled && <> · } - {votesCount} - {poll.get('expires_at') && <> · {timeRemaining}} -
    -
    - ); - } - -} - -export default injectIntl(withIdentity(Poll)); diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx new file mode 100644 index 00000000000..48f4214c261 --- /dev/null +++ b/app/javascript/mastodon/components/poll.tsx @@ -0,0 +1,352 @@ +import type { KeyboardEventHandler } from 'react'; +import { useCallback, useMemo, useState } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { animated, useSpring } from '@react-spring/web'; +import escapeTextContentForBrowser from 'escape-html'; +import { debounce } from 'lodash'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import { openModal } from 'mastodon/actions/modal'; +import { fetchPoll, vote } from 'mastodon/actions/polls'; +import { Icon } from 'mastodon/components/icon'; +import emojify from 'mastodon/features/emoji/emoji'; +import { useIdentity } from 'mastodon/identity_context'; +import { reduceMotion } from 'mastodon/initial_state'; +import { makeEmojiMap } from 'mastodon/models/custom_emoji'; +import type * as Model from 'mastodon/models/poll'; +import type { Status } from 'mastodon/models/status'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +import { RelativeTimestamp } from './relative_timestamp'; + +const messages = defineMessages({ + closed: { + id: 'poll.closed', + defaultMessage: 'Closed', + }, + voted: { + id: 'poll.voted', + defaultMessage: 'You voted for this answer', + }, + votes: { + id: 'poll.votes', + defaultMessage: '{votes, plural, one {# vote} other {# votes}}', + }, +}); + +interface PollProps { + pollId: string; + status: Status; + lang?: string; + disabled?: boolean; +} + +export const Poll: React.FC = (props) => { + const { pollId, status } = props; + + // Third party hooks + const poll = useAppSelector((state) => state.polls.get(pollId)); + const identity = useIdentity(); + const intl = useIntl(); + const dispatch = useAppDispatch(); + + // State + const [revealed, setRevealed] = useState(false); + const [selected, setSelected] = useState>({}); + + // Derived values + const expired = useMemo(() => { + if (!poll) { + return false; + } + const expiresAt = poll.get('expires_at'); + return poll.get('expired') || new Date(expiresAt).getTime() < Date.now(); + }, [poll]); + const timeRemaining = useMemo(() => { + if (!poll) { + return null; + } + if (expired) { + return intl.formatMessage(messages.closed); + } + return ; + }, [expired, intl, poll]); + const votesCount = useMemo(() => { + if (!poll) { + return null; + } + if (poll.get('voters_count')) { + return ( + + ); + } + return ( + + ); + }, [poll]); + + const disabled = + props.disabled || Object.values(selected).every((item) => !item); + + // Event handlers + const handleVote = useCallback(() => { + if (disabled) { + return; + } + + if (identity.signedIn) { + void dispatch(vote({ pollId, choices: Object.keys(selected) })); + } else { + dispatch( + openModal({ + modalType: 'INTERACTION', + modalProps: { + type: 'vote', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + }), + ); + } + }, [disabled, dispatch, identity, pollId, selected, status]); + + const handleReveal = useCallback(() => { + setRevealed(true); + }, []); + + const handleRefresh = useCallback(() => { + if (disabled) { + return; + } + debounce( + () => { + void dispatch(fetchPoll({ pollId })); + }, + 1000, + { leading: true }, + ); + }, [disabled, dispatch, pollId]); + + const handleOptionChange = useCallback( + (choiceIndex: number) => { + if (!poll) { + return; + } + if (poll.get('multiple')) { + setSelected((prev) => ({ + ...prev, + [choiceIndex]: !prev[choiceIndex], + })); + } else { + setSelected({ [choiceIndex]: true }); + } + }, + [poll], + ); + + if (!poll) { + return null; + } + const showResults = poll.get('voted') || revealed || expired; + + return ( +
    +
      + {poll.get('options').map((option, i) => ( + + ))} +
    + +
    + {!showResults && ( + + )} + {!showResults && ( + <> + {' '} + ·{' '} + + )} + {showResults && !disabled && ( + <> + {' '} + ·{' '} + + )} + {votesCount} + {poll.get('expires_at') && <> · {timeRemaining}} +
    +
    + ); +}; + +type PollOptionProps = Pick & { + active: boolean; + onChange: (index: number) => void; + poll: Model.Poll; + option: Model.PollOption; + index: number; + showResults?: boolean; +}; + +const PollOption: React.FC = (props) => { + const { active, lang, disabled, poll, option, index, showResults, onChange } = + props; + const voted = option.get('voted') || poll.get('own_votes')?.includes(index); + const title = + (option.getIn(['translation', 'title']) as string) || option.get('title'); + + const intl = useIntl(); + + // Derived values + const percent = useMemo(() => { + const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); + return pollVotesCount === 0 + ? 0 + : (option.get('votes_count') / pollVotesCount) * 100; + }, [option, poll]); + const isLeading = useMemo( + () => + poll + .get('options') + .filterNot((other) => other.get('title') === option.get('title')) + .every( + (other) => option.get('votes_count') >= other.get('votes_count'), + ), + [poll, option], + ); + const titleHtml = useMemo(() => { + let titleHtml = + (option.getIn(['translation', 'titleHtml']) as string) || + option.get('titleHtml'); + + if (!titleHtml) { + const emojiMap = makeEmojiMap(poll.get('emojis')); + titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); + } + + return titleHtml; + }, [option, poll, title]); + + // Handlers + const handleOptionChange = useCallback(() => { + onChange(index); + }, [index, onChange]); + const handleOptionKeyPress: KeyboardEventHandler = useCallback( + (event) => { + if (event.key === 'Enter' || event.key === ' ') { + onChange(index); + event.stopPropagation(); + event.preventDefault(); + } + }, + [index, onChange], + ); + + const widthSpring = useSpring({ + from: { + width: '0%', + }, + to: { + width: `${percent}%`, + }, + immediate: reduceMotion, + }); + + return ( +
  • + + + {showResults && ( + + )} +
  • + ); +}; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index aa8b0603843..cc208887997 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -11,7 +11,7 @@ import { connect } from 'react-redux'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import { Icon } from 'mastodon/components/icon'; -import PollContainer from 'mastodon/containers/poll_container'; +import { Poll } from 'mastodon/components/poll'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; @@ -245,7 +245,7 @@ class StatusContent extends PureComponent { ); const poll = !!status.get('poll') && ( - + ); if (this.props.onClick) { diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx index f5f38d89029..df162730cf2 100644 --- a/app/javascript/mastodon/containers/media_container.jsx +++ b/app/javascript/mastodon/containers/media_container.jsx @@ -7,7 +7,7 @@ import { fromJS } from 'immutable'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import MediaGallery from 'mastodon/components/media_gallery'; import ModalRoot from 'mastodon/components/modal_root'; -import Poll from 'mastodon/components/poll'; +import { Poll } from 'mastodon/components/poll'; import Audio from 'mastodon/features/audio'; import Card from 'mastodon/features/status/components/card'; import MediaModal from 'mastodon/features/ui/components/media_modal'; diff --git a/app/javascript/mastodon/containers/poll_container.js b/app/javascript/mastodon/containers/poll_container.js deleted file mode 100644 index 7ca840138db..00000000000 --- a/app/javascript/mastodon/containers/poll_container.js +++ /dev/null @@ -1,38 +0,0 @@ -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import { openModal } from 'mastodon/actions/modal'; -import { fetchPoll, vote } from 'mastodon/actions/polls'; -import Poll from 'mastodon/components/poll'; - -const mapDispatchToProps = (dispatch, { pollId }) => ({ - refresh: debounce( - () => { - dispatch(fetchPoll({ pollId })); - }, - 1000, - { leading: true }, - ), - - onVote (choices) { - dispatch(vote({ pollId, choices })); - }, - - onInteractionModal (type, status) { - dispatch(openModal({ - modalType: 'INTERACTION', - modalProps: { - type, - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), - }, - })); - } -}); - -const mapStateToProps = (state, { pollId }) => ({ - poll: state.polls.get(pollId), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Poll); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 75f4720fb30..3611a74b4f7 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -20,7 +20,6 @@ import PollButtonContainer from '../containers/poll_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import UploadButtonContainer from '../containers/upload_button_container'; -import WarningContainer from '../containers/warning_container'; import { countableText } from '../util/counter'; import { CharacterCounter } from './character_counter'; @@ -30,6 +29,7 @@ import { NavigationBar } from './navigation_bar'; import { PollForm } from "./poll_form"; import { ReplyIndicator } from './reply_indicator'; import { UploadForm } from './upload_form'; +import { Warning } from './warning'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; @@ -233,7 +233,7 @@ class ComposeForm extends ImmutablePureComponent {
    {!withoutNavigation && } - +
    diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.jsx b/app/javascript/mastodon/features/compose/components/upload_progress.jsx deleted file mode 100644 index fd0c8f45302..00000000000 --- a/app/javascript/mastodon/features/compose/components/upload_progress.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import spring from 'react-motion/lib/spring'; - -import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -import Motion from '../../ui/util/optional_motion'; - -export const UploadProgress = ({ active, progress, isProcessing }) => { - if (!active) { - return null; - } - - let message; - - if (isProcessing) { - message = ; - } else { - message = ; - } - - return ( -
    - - -
    - {message} - -
    - - {({ width }) => -
    - } - -
    -
    -
    - ); -}; - -UploadProgress.propTypes = { - active: PropTypes.bool, - progress: PropTypes.number, - isProcessing: PropTypes.bool, -}; diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.tsx b/app/javascript/mastodon/features/compose/components/upload_progress.tsx new file mode 100644 index 00000000000..35af03306f0 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_progress.tsx @@ -0,0 +1,61 @@ +import { FormattedMessage } from 'react-intl'; + +import { animated, useSpring } from '@react-spring/web'; + +import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react'; +import { Icon } from 'mastodon/components/icon'; +import { reduceMotion } from 'mastodon/initial_state'; + +interface UploadProgressProps { + active: boolean; + progress: number; + isProcessing: boolean; +} + +export const UploadProgress: React.FC = ({ + active, + progress, + isProcessing, +}) => { + const styles = useSpring({ + from: { width: '0%' }, + to: { width: `${progress}%` }, + reset: true, + immediate: reduceMotion, + }); + if (!active) { + return null; + } + + let message; + + if (isProcessing) { + message = ( + + ); + } else { + message = ( + + ); + } + + return ( +
    + + +
    + {message} + +
    + +
    +
    +
    + ); +}; diff --git a/app/javascript/mastodon/features/compose/components/warning.jsx b/app/javascript/mastodon/features/compose/components/warning.jsx deleted file mode 100644 index c5babc30a5a..00000000000 --- a/app/javascript/mastodon/features/compose/components/warning.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import spring from 'react-motion/lib/spring'; - -import Motion from '../../ui/util/optional_motion'; - -export default class Warning extends PureComponent { - - static propTypes = { - message: PropTypes.node.isRequired, - }; - - render () { - const { message } = this.props; - - return ( - - {({ opacity, scaleX, scaleY }) => ( -
    - {message} -
    - )} -
    - ); - } - -} diff --git a/app/javascript/mastodon/features/compose/components/warning.tsx b/app/javascript/mastodon/features/compose/components/warning.tsx new file mode 100644 index 00000000000..5c5482fb9d7 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/warning.tsx @@ -0,0 +1,96 @@ +import { FormattedMessage } from 'react-intl'; + +import { createSelector } from '@reduxjs/toolkit'; + +import { animated, useSpring } from '@react-spring/web'; + +import { me } from 'mastodon/initial_state'; +import { useAppSelector } from 'mastodon/store'; +import type { RootState } from 'mastodon/store'; +import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags'; + +const selector = createSelector( + (state: RootState) => state.compose.get('privacy') as string, + (state: RootState) => !!state.compose.getIn(['accounts', me, 'locked']), + (state: RootState) => state.compose.get('text') as string, + (privacy, locked, text) => ({ + needsLockWarning: privacy === 'private' && !locked, + hashtagWarning: privacy !== 'public' && HASHTAG_PATTERN_REGEX.test(text), + directMessageWarning: privacy === 'direct', + }), +); + +export const Warning = () => { + const { needsLockWarning, hashtagWarning, directMessageWarning } = + useAppSelector(selector); + if (needsLockWarning) { + return ( + + + + + ), + }} + /> + + ); + } + + if (hashtagWarning) { + return ( + + + + ); + } + + if (directMessageWarning) { + return ( + + {' '} + + + + + ); + } + + return null; +}; + +export const WarningMessage: React.FC = ({ + children, +}) => { + const styles = useSpring({ + from: { + opacity: 0, + transform: 'scale(0.85, 0.75)', + }, + to: { + opacity: 1, + transform: 'scale(1, 1)', + }, + }); + return ( + + {children} + + ); +}; diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.jsx b/app/javascript/mastodon/features/compose/containers/warning_container.jsx deleted file mode 100644 index 7a212342c79..00000000000 --- a/app/javascript/mastodon/features/compose/containers/warning_container.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import { connect } from 'react-redux'; - -import { me } from 'mastodon/initial_state'; -import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags'; - -import Warning from '../components/warning'; - -const mapStateToProps = state => ({ - needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), - hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])), - directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', -}); - -const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { - if (needsLockWarning) { - return }} />} />; - } - - if (hashtagWarning) { - return } />; - } - - if (directMessageWarning) { - const message = ( - - - - ); - - return ; - } - - return null; -}; - -WarningWrapper.propTypes = { - needsLockWarning: PropTypes.bool, - hashtagWarning: PropTypes.bool, - directMessageWarning: PropTypes.bool, -}; - -export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.jsx b/app/javascript/mastodon/features/getting_started/components/announcements.jsx index ad66d2e5faf..f5f593860f1 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.jsx +++ b/app/javascript/mastodon/features/getting_started/components/announcements.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; +import { PureComponent, useCallback, useMemo } from 'react'; import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; @@ -9,8 +9,7 @@ import { withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import TransitionMotion from 'react-motion/lib/TransitionMotion'; -import spring from 'react-motion/lib/spring'; +import { animated, useTransition } from '@react-spring/web'; import ReactSwipeableViews from 'react-swipeable-views'; import elephantUIPlane from '@/images/elephant_ui_plane.svg'; @@ -239,72 +238,76 @@ class Reaction extends ImmutablePureComponent { } return ( - + ); } } -class ReactionsBar extends ImmutablePureComponent { +const ReactionsBar = ({ + announcementId, + reactions, + emojiMap, + addReaction, + removeReaction, +}) => { + const visibleReactions = useMemo(() => reactions.filter(x => x.get('count') > 0).toArray(), [reactions]); - static propTypes = { - announcementId: PropTypes.string.isRequired, - reactions: ImmutablePropTypes.list.isRequired, - addReaction: PropTypes.func.isRequired, - removeReaction: PropTypes.func.isRequired, - emojiMap: ImmutablePropTypes.map.isRequired, - }; + const handleEmojiPick = useCallback((emoji) => { + addReaction(announcementId, emoji.native.replaceAll(/:/g, '')); + }, [addReaction, announcementId]); - handleEmojiPick = data => { - const { addReaction, announcementId } = this.props; - addReaction(announcementId, data.native.replace(/:/g, '')); - }; + const transitions = useTransition(visibleReactions, { + from: { + scale: 0, + }, + enter: { + scale: 1, + }, + leave: { + scale: 0, + }, + immediate: reduceMotion, + keys: visibleReactions.map(x => x.get('name')), + }); - willEnter () { - return { scale: reduceMotion ? 1 : 0 }; - } + return ( +
    + {transitions(({ scale }, reaction) => ( + `scale(${s})`) }} + addReaction={addReaction} + removeReaction={removeReaction} + announcementId={announcementId} + emojiMap={emojiMap} + /> + ))} - willLeave () { - return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; - } - - render () { - const { reactions } = this.props; - const visibleReactions = reactions.filter(x => x.get('count') > 0); - - const styles = visibleReactions.map(reaction => ({ - key: reaction.get('name'), - data: reaction, - style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, - })).toArray(); - - return ( - - {items => ( -
    - {items.map(({ key, data, style }) => ( - - ))} - - {visibleReactions.size < 8 && } />} -
    - )} -
    - ); - } - -} + {visibleReactions.length < 8 && ( + } + /> + )} +
    + ); +}; +ReactionsBar.propTypes = { + announcementId: PropTypes.string.isRequired, + reactions: ImmutablePropTypes.list.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, +}; class Announcement extends ImmutablePureComponent { diff --git a/app/javascript/mastodon/features/ui/components/upload_area.jsx b/app/javascript/mastodon/features/ui/components/upload_area.jsx deleted file mode 100644 index b2702d35efd..00000000000 --- a/app/javascript/mastodon/features/ui/components/upload_area.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import spring from 'react-motion/lib/spring'; - -import Motion from '../util/optional_motion'; - -export default class UploadArea extends PureComponent { - - static propTypes = { - active: PropTypes.bool, - onClose: PropTypes.func, - }; - - handleKeyUp = (e) => { - const keyCode = e.keyCode; - if (this.props.active) { - switch(keyCode) { - case 27: - e.preventDefault(); - e.stopPropagation(); - this.props.onClose(); - break; - } - } - }; - - componentDidMount () { - window.addEventListener('keyup', this.handleKeyUp, false); - } - - componentWillUnmount () { - window.removeEventListener('keyup', this.handleKeyUp); - } - - render () { - const { active } = this.props; - - return ( - - {({ backgroundOpacity, backgroundScale }) => ( -
    -
    -
    -
    -
    -
    - )} - - ); - } - -} diff --git a/app/javascript/mastodon/features/ui/components/upload_area.tsx b/app/javascript/mastodon/features/ui/components/upload_area.tsx new file mode 100644 index 00000000000..87ac090e7e9 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/upload_area.tsx @@ -0,0 +1,78 @@ +import { useCallback, useEffect } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { animated, config, useSpring } from '@react-spring/web'; + +import { reduceMotion } from 'mastodon/initial_state'; + +interface UploadAreaProps { + active?: boolean; + onClose: () => void; +} + +export const UploadArea: React.FC = ({ active, onClose }) => { + const handleKeyUp = useCallback( + (e: KeyboardEvent) => { + if (active && e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }, + [active, onClose], + ); + + useEffect(() => { + window.addEventListener('keyup', handleKeyUp, false); + + return () => { + window.removeEventListener('keyup', handleKeyUp); + }; + }, [handleKeyUp]); + + const wrapperAnimStyles = useSpring({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + reverse: !active, + immediate: reduceMotion, + }); + const backgroundAnimStyles = useSpring({ + from: { + transform: 'scale(0.95)', + }, + to: { + transform: 'scale(1)', + }, + reverse: !active, + config: config.wobbly, + immediate: reduceMotion, + }); + + return ( + +
    + +
    + +
    +
    +
    + ); +}; diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index d5ff6d148ce..a1cb8212d23 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -30,7 +30,7 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding import BundleColumnError from './components/bundle_column_error'; import Header from './components/header'; -import UploadArea from './components/upload_area'; +import { UploadArea } from './components/upload_area'; import ColumnsAreaContainer from './containers/columns_area_container'; import LoadingBarContainer from './containers/loading_bar_container'; import ModalContainer from './containers/modal_container'; diff --git a/app/javascript/mastodon/features/ui/util/optional_motion.js b/app/javascript/mastodon/features/ui/util/optional_motion.js deleted file mode 100644 index 0b6d4d97f79..00000000000 --- a/app/javascript/mastodon/features/ui/util/optional_motion.js +++ /dev/null @@ -1,7 +0,0 @@ -import Motion from 'react-motion/lib/Motion'; - -import { reduceMotion } from '../../../initial_state'; - -import ReducedMotion from './reduced_motion'; - -export default reduceMotion ? ReducedMotion : Motion; diff --git a/app/javascript/mastodon/features/ui/util/reduced_motion.jsx b/app/javascript/mastodon/features/ui/util/reduced_motion.jsx deleted file mode 100644 index fd044497f80..00000000000 --- a/app/javascript/mastodon/features/ui/util/reduced_motion.jsx +++ /dev/null @@ -1,45 +0,0 @@ -// Like react-motion's Motion, but reduces all animations to cross-fades -// for the benefit of users with motion sickness. -import PropTypes from 'prop-types'; -import { Component } from 'react'; - -import Motion from 'react-motion/lib/Motion'; - -const stylesToKeep = ['opacity', 'backgroundOpacity']; - -const extractValue = (value) => { - // This is either an object with a "val" property or it's a number - return (typeof value === 'object' && value && 'val' in value) ? value.val : value; -}; - -class ReducedMotion extends Component { - - static propTypes = { - defaultStyle: PropTypes.object, - style: PropTypes.object, - children: PropTypes.func, - }; - - render() { - - const { style, defaultStyle, children } = this.props; - - Object.keys(style).forEach(key => { - if (stylesToKeep.includes(key)) { - return; - } - // If it's setting an x or height or scale or some other value, we need - // to preserve the end-state value without actually animating it - style[key] = defaultStyle[key] = extractValue(style[key]); - }); - - return ( - - {children} - - ); - } - -} - -export default ReducedMotion; diff --git a/package.json b/package.json index 38c8a3abb1b..9053a45fd1b 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,6 @@ "react-immutable-proptypes": "^2.2.0", "react-immutable-pure-component": "^2.2.2", "react-intl": "^7.0.0", - "react-motion": "^0.5.2", "react-overlays": "^5.2.1", "react-redux": "^9.0.4", "react-redux-loading-bar": "^5.0.8", @@ -164,7 +163,6 @@ "@types/react-dom": "^18.2.4", "@types/react-helmet": "^6.1.6", "@types/react-immutable-proptypes": "^2.1.0", - "@types/react-motion": "^0.0.40", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@types/react-sparklines": "^1.7.2", diff --git a/yarn.lock b/yarn.lock index 66e69f563bf..cec0741a60a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2771,7 +2771,6 @@ __metadata: "@types/react-dom": "npm:^18.2.4" "@types/react-helmet": "npm:^6.1.6" "@types/react-immutable-proptypes": "npm:^2.1.0" - "@types/react-motion": "npm:^0.0.40" "@types/react-router": "npm:^5.1.20" "@types/react-router-dom": "npm:^5.3.3" "@types/react-sparklines": "npm:^1.7.2" @@ -2850,7 +2849,6 @@ __metadata: react-immutable-proptypes: "npm:^2.2.0" react-immutable-pure-component: "npm:^2.2.2" react-intl: "npm:^7.0.0" - react-motion: "npm:^0.5.2" react-overlays: "npm:^5.2.1" react-redux: "npm:^9.0.4" react-redux-loading-bar: "npm:^5.0.8" @@ -4050,15 +4048,6 @@ __metadata: languageName: node linkType: hard -"@types/react-motion@npm:^0.0.40": - version: 0.0.40 - resolution: "@types/react-motion@npm:0.0.40" - dependencies: - "@types/react": "npm:*" - checksum: 10c0/8a560051be917833fdbe051185b53aeafbe8657968ac8e073ac874b9a55c6f16e3793748b13cfb9bd6d9a3d27aba116d6f8f296ec1950f4175dc94d17c5e8470 - languageName: node - linkType: hard - "@types/react-router-dom@npm:^5.3.3": version: 5.3.3 resolution: "@types/react-router-dom@npm:5.3.3" @@ -13189,20 +13178,6 @@ __metadata: languageName: node linkType: hard -"performance-now@npm:^0.2.0": - version: 0.2.0 - resolution: "performance-now@npm:0.2.0" - checksum: 10c0/d7f3824e443491208f7124b45d3280dbff889f8f048c3aee507109c24644d51a226eb07fd7ac51dd0eef144639590c57410c2d167bd4fdf0c5caa0101a449c3d - languageName: node - linkType: hard - -"performance-now@npm:^2.1.0": - version: 2.1.0 - resolution: "performance-now@npm:2.1.0" - checksum: 10c0/22c54de06f269e29f640e0e075207af57de5052a3d15e360c09b9a8663f393f6f45902006c1e71aa8a5a1cdfb1a47fe268826f8496d6425c362f00f5bc3e85d9 - languageName: node - linkType: hard - "pg-cloudflare@npm:^1.1.1": version: 1.1.1 resolution: "pg-cloudflare@npm:1.1.1" @@ -14465,7 +14440,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.5.10, prop-types@npm:^15.5.4, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.5.10, prop-types@npm:^15.5.4, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -14596,15 +14571,6 @@ __metadata: languageName: node linkType: hard -"raf@npm:^3.1.0": - version: 3.4.1 - resolution: "raf@npm:3.4.1" - dependencies: - performance-now: "npm:^2.1.0" - checksum: 10c0/337f0853c9e6a77647b0f499beedafea5d6facfb9f2d488a624f88b03df2be72b8a0e7f9118a3ff811377d534912039a3311815700d2b6d2313f82f736f9eb6e - languageName: node - linkType: hard - "randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" @@ -14777,19 +14743,6 @@ __metadata: languageName: node linkType: hard -"react-motion@npm:^0.5.2": - version: 0.5.2 - resolution: "react-motion@npm:0.5.2" - dependencies: - performance-now: "npm:^0.2.0" - prop-types: "npm:^15.5.8" - raf: "npm:^3.1.0" - peerDependencies: - react: ^0.14.9 || ^15.3.0 || ^16.0.0 - checksum: 10c0/4ea6f1cc7079f0161fd786cc755133a822d87d9c0510369b8fb348d9ad602111efa2e3496dbcc390c967229e39e3eb5f6dd5dd6d3d124289443de31d6035a6c8 - languageName: node - linkType: hard - "react-overlays@npm:^5.2.1": version: 5.2.1 resolution: "react-overlays@npm:5.2.1"