2
2
mirror of https://github.com/mastodon/mastodon synced 2025-04-12 00:56:38 +02:00

Remove react-motion library (#34293)

This commit is contained in:
Echo 2025-03-28 13:34:51 +01:00 committed by GitHub
parent 97b9994743
commit 902aab1245
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 692 additions and 670 deletions

View File

@ -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<Props> = ({ 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 <ShortNumber value={value} />;
}
const styles = [
{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
},
];
return (
<TransitionMotion
styles={styles}
willEnter={willEnter}
willLeave={willLeave}
>
{(items) => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span
key={key}
style={{
position:
direction * (style.y ?? 0) > 0 ? 'absolute' : 'static',
transform: `translateY(${(style.y ?? 0) * 100}%)`,
}}
>
<ShortNumber value={data as number} />
</span>
))}
</span>
<span className='animated-number'>
<animated.span style={styles}>
<ShortNumber value={value} />
</animated.span>
{value !== previousValue && (
<animated.span
style={{
...styles,
position: 'absolute',
top: `${-100 * direction}%`, // Adds extra space on top of translateY
}}
role='presentation'
>
<ShortNumber value={previousValue} />
</animated.span>
)}
</TransitionMotion>
</span>
);
};

View File

@ -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 (
<li key={option.get('title')}>
<label className={classNames('poll__option', { selectable: !showResults })}>
<input
name='vote-options'
type={poll.get('multiple') ? 'checkbox' : 'radio'}
value={optionIndex}
checked={active}
onChange={this.handleOptionChange}
disabled={disabled}
/>
{!showResults && (
<span
className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
tabIndex={0}
role={poll.get('multiple') ? 'checkbox' : 'radio'}
onKeyPress={this.handleOptionKeyPress}
aria-checked={active}
aria-label={title}
lang={lang}
data-index={optionIndex}
/>
)}
{showResults && (
<span
className='poll__number'
title={intl.formatMessage(messages.votes, {
votes: option.get('votes_count'),
})}
>
{Math.round(percent)}%
</span>
)}
<span
className='poll__option__text translate'
lang={lang}
dangerouslySetInnerHTML={{ __html: titleHtml }}
/>
{!!voted && <span className='poll__voted'>
<Icon id='check' icon={CheckIcon} className='poll__voted__mark' title={intl.formatMessage(messages.voted)} />
</span>}
</label>
{showResults && (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
{({ width }) =>
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
}
</Motion>
)}
</li>
);
}
render () {
const { poll, intl } = this.props;
const { revealed, expired } = this.state;
if (!poll) {
return null;
}
const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
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 = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
} else {
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
}
return (
<div className='poll'>
<ul>
{poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
</ul>
<div className='poll__footer'>
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
{!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>}
{showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>}
{votesCount}
{poll.get('expires_at') && <> · {timeRemaining}</>}
</div>
</div>
);
}
}
export default injectIntl(withIdentity(Poll));

View File

@ -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<PollProps> = (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<Record<string, boolean>>({});
// 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 <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
}, [expired, intl, poll]);
const votesCount = useMemo(() => {
if (!poll) {
return null;
}
if (poll.get('voters_count')) {
return (
<FormattedMessage
id='poll.total_people'
defaultMessage='{count, plural, one {# person} other {# people}}'
values={{ count: poll.get('voters_count') }}
/>
);
}
return (
<FormattedMessage
id='poll.total_votes'
defaultMessage='{count, plural, one {# vote} other {# votes}}'
values={{ count: poll.get('votes_count') }}
/>
);
}, [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 (
<div className='poll'>
<ul>
{poll.get('options').map((option, i) => (
<PollOption
key={option.get('title') || i}
index={i}
poll={poll}
option={option}
showResults={showResults}
active={!!selected[i]}
onChange={handleOptionChange}
/>
))}
</ul>
<div className='poll__footer'>
{!showResults && (
<button
className='button button-secondary'
disabled={disabled}
onClick={handleVote}
>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</button>
)}
{!showResults && (
<>
<button className='poll__link' onClick={handleReveal}>
<FormattedMessage id='poll.reveal' defaultMessage='See results' />
</button>{' '}
·{' '}
</>
)}
{showResults && !disabled && (
<>
<button className='poll__link' onClick={handleRefresh}>
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
</button>{' '}
·{' '}
</>
)}
{votesCount}
{poll.get('expires_at') && <> · {timeRemaining}</>}
</div>
</div>
);
};
type PollOptionProps = Pick<PollProps, 'disabled' | 'lang'> & {
active: boolean;
onChange: (index: number) => void;
poll: Model.Poll;
option: Model.PollOption;
index: number;
showResults?: boolean;
};
const PollOption: React.FC<PollOptionProps> = (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 (
<li>
<label
className={classNames('poll__option', { selectable: !showResults })}
>
<input
name='vote-options'
type={poll.get('multiple') ? 'checkbox' : 'radio'}
value={index}
checked={active}
onChange={handleOptionChange}
disabled={disabled}
/>
{!showResults && (
<span
className={classNames('poll__input', {
checkbox: poll.get('multiple'),
active,
})}
tabIndex={0}
role={poll.get('multiple') ? 'checkbox' : 'radio'}
onKeyDown={handleOptionKeyPress}
aria-checked={active}
aria-label={title}
lang={lang}
data-index={index}
/>
)}
{showResults && (
<span
className='poll__number'
title={intl.formatMessage(messages.votes, {
votes: option.get('votes_count'),
})}
>
{Math.round(percent)}%
</span>
)}
<span
className='poll__option__text translate'
lang={lang}
dangerouslySetInnerHTML={{ __html: titleHtml }}
/>
{!!voted && (
<span className='poll__voted'>
<Icon
id='check'
icon={CheckIcon}
className='poll__voted__mark'
title={intl.formatMessage(messages.voted)}
/>
</span>
)}
</label>
{showResults && (
<animated.span
className={classNames('poll__chart', { leading: isLeading })}
style={widthSpring}
/>
)}
</li>
);
};

View File

@ -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') && (
<PollContainer pollId={status.get('poll')} status={status} lang={language} />
<Poll pollId={status.get('poll')} status={status} lang={language} />
);
if (this.props.onClick) {

View File

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

View File

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

View File

@ -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 {
<form className='compose-form' onSubmit={this.handleSubmit}>
<ReplyIndicator />
{!withoutNavigation && <NavigationBar />}
<WarningContainer />
<Warning />
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
<div className='compose-form__scrollable'>

View File

@ -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 = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
} else {
message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
}
return (
<div className='upload-progress'>
<Icon id='upload' icon={UploadFileIcon} />
<div className='upload-progress__message'>
{message}
<div className='upload-progress__backdrop'>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
{({ width }) =>
<div className='upload-progress__tracker' style={{ width: `${width}%` }} />
}
</Motion>
</div>
</div>
</div>
);
};
UploadProgress.propTypes = {
active: PropTypes.bool,
progress: PropTypes.number,
isProcessing: PropTypes.bool,
};

View File

@ -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<UploadProgressProps> = ({
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 = (
<FormattedMessage
id='upload_progress.processing'
defaultMessage='Processing…'
/>
);
} else {
message = (
<FormattedMessage
id='upload_progress.label'
defaultMessage='Uploading…'
/>
);
}
return (
<div className='upload-progress'>
<Icon id='upload' icon={UploadFileIcon} />
<div className='upload-progress__message'>
{message}
<div className='upload-progress__backdrop'>
<animated.div className='upload-progress__tracker' style={styles} />
</div>
</div>
</div>
);
};

View File

@ -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 (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
{message}
</div>
)}
</Motion>
);
}
}

View File

@ -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 (
<WarningMessage>
<FormattedMessage
id='compose_form.lock_disclaimer'
defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.'
values={{
locked: (
<a href='/settings/profile'>
<FormattedMessage
id='compose_form.lock_disclaimer.lock'
defaultMessage='locked'
/>
</a>
),
}}
/>
</WarningMessage>
);
}
if (hashtagWarning) {
return (
<WarningMessage>
<FormattedMessage
id='compose_form.hashtag_warning'
defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag."
/>
</WarningMessage>
);
}
if (directMessageWarning) {
return (
<WarningMessage>
<FormattedMessage
id='compose_form.encryption_warning'
defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.'
/>{' '}
<a href='/terms' target='_blank'>
<FormattedMessage
id='compose_form.direct_message_warning_learn_more'
defaultMessage='Learn more'
/>
</a>
</WarningMessage>
);
}
return null;
};
export const WarningMessage: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const styles = useSpring({
from: {
opacity: 0,
transform: 'scale(0.85, 0.75)',
},
to: {
opacity: 1,
transform: 'scale(1, 1)',
},
});
return (
<animated.div className='compose-form__warning' style={styles}>
{children}
</animated.div>
);
};

View File

@ -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 <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
}
if (hashtagWarning) {
return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag." />} />;
}
if (directMessageWarning) {
const message = (
<span>
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
</span>
);
return <Warning message={message} />;
}
return null;
};
WarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
};
export default connect(mapStateToProps)(WarningWrapper);

View File

@ -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 (
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
<animated.button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
</button>
</animated.button>
);
}
}
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 (
<div
className={classNames('reactions-bar', {
'reactions-bar--empty': visibleReactions.length === 0
})}
>
{transitions(({ scale }, reaction) => (
<Reaction
key={reaction.get('name')}
reaction={reaction}
style={{ transform: scale.to((s) => `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 (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
announcementId={this.props.announcementId}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap}
/>
))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' icon={AddIcon} />} />}
</div>
)}
</TransitionMotion>
);
}
}
{visibleReactions.length < 8 && (
<EmojiPickerDropdown
onPickEmoji={handleEmojiPick}
button={<Icon id='plus' icon={AddIcon} />}
/>
)}
</div>
);
};
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 {

View File

@ -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 (
<Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
{({ backgroundOpacity, backgroundScale }) => (
<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
<div className='upload-area__drop'>
<div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} />
<div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
</div>
</div>
)}
</Motion>
);
}
}

View File

@ -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<UploadAreaProps> = ({ 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 (
<animated.div
className='upload-area'
style={{
...wrapperAnimStyles,
visibility: active ? 'visible' : 'hidden',
}}
>
<div className='upload-area__drop'>
<animated.div
className='upload-area__background'
style={backgroundAnimStyles}
/>
<div className='upload-area__content'>
<FormattedMessage
id='upload_area.title'
defaultMessage='Drag & drop to upload'
/>
</div>
</div>
</animated.div>
);
};

View File

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

View File

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

View File

@ -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 (
<Motion style={style} defaultStyle={defaultStyle}>
{children}
</Motion>
);
}
}
export default ReducedMotion;

View File

@ -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",

View File

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