diff --git a/app/javascript/mastodon/api_types/polls.ts b/app/javascript/mastodon/api_types/polls.ts
index 275ca29fd7..d7c5907736 100644
--- a/app/javascript/mastodon/api_types/polls.ts
+++ b/app/javascript/mastodon/api_types/polls.ts
@@ -13,7 +13,7 @@ export interface ApiPollJSON {
expired: boolean;
multiple: boolean;
votes_count: number;
- voters_count: number;
+ voters_count: null | number;
options: ApiPollOptionJSON[];
emojis: ApiCustomEmojiJSON[];
diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx
deleted file mode 100644
index 1326131009..0000000000
--- 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 0000000000..a3664a78d1
--- /dev/null
+++ b/app/javascript/mastodon/components/poll.tsx
@@ -0,0 +1,333 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import { spring } from 'react-motion';
+
+import CheckIcon from '@/material-icons/400-24px/check.svg?react';
+import { Icon } from 'mastodon/components/icon';
+import Motion from 'mastodon/features/ui/util/optional_motion';
+import { useIdentity } from 'mastodon/identity_context';
+import type {
+ Poll as PollModel,
+ PollOption as PollOptionModel,
+} from 'mastodon/models/poll';
+import type { Status } from 'mastodon/models/status';
+
+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}}',
+ },
+});
+
+export const PollOption: React.FC<{
+ option: PollOptionModel;
+ optionIndex: number;
+ showResults: boolean;
+ percent: number;
+ voted: boolean;
+ leading: boolean;
+ multiple: boolean;
+ lang: string;
+ disabled: boolean;
+ active: boolean;
+ toggleOption: () => void;
+}> = ({
+ option,
+ optionIndex,
+ percent,
+ leading,
+ voted,
+ multiple,
+ showResults,
+ lang,
+ disabled,
+ active,
+ toggleOption,
+}) => {
+ const intl = useIntl();
+ const title = option.translation?.title ?? option.title;
+ const titleHtml = option.translation?.titleHtml ?? option.titleHtml;
+
+ const handleOptionKeyPress = useCallback<
+ React.KeyboardEventHandler
+ >(
+ (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ toggleOption();
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ },
+ [toggleOption],
+ );
+
+ return (
+
+
+
+ {showResults && (
+
+ {({ width }) => (
+
+ )}
+
+ )}
+
+ );
+};
+
+export const Poll: React.FC<{
+ poll: PollModel;
+ status: Status;
+ lang: string;
+ disabled?: boolean;
+ refresh?: () => void;
+ onVote: (votes: string[]) => void;
+ onInteractionModal: (interactionType: string, status: Status) => void;
+}> = ({
+ poll,
+ lang,
+ disabled,
+ refresh,
+ onVote,
+ onInteractionModal,
+ status,
+}) => {
+ const intl = useIntl();
+
+ const expires_at = poll.expires_at;
+ const [expired, setExpired] = useState(
+ poll.expired ||
+ (!!expires_at && new Date(expires_at).getTime() < Date.now()),
+ );
+
+ const [revealed, setRevealed] = useState(false);
+
+ const handleReveal = useCallback(() => {
+ setRevealed(true);
+ }, []);
+
+ const [selected, setSelected] = useState>(new Set());
+
+ const toggleOption = useCallback(
+ (option: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+
+ if (poll.multiple) {
+ if (next.has(option)) next.delete(option);
+ else next.add(option);
+ } else {
+ next.add(option);
+ }
+ return next;
+ });
+ },
+ [poll.multiple],
+ );
+
+ const makeToggleOption = (option: string) => () => {
+ toggleOption(option);
+ };
+
+ const { signedIn } = useIdentity();
+
+ const handleRefresh = useCallback(() => {
+ if (disabled) {
+ return;
+ }
+
+ refresh?.();
+ }, [refresh, disabled]);
+
+ const handleVote = useCallback(() => {
+ if (disabled) {
+ return;
+ }
+
+ if (signedIn) {
+ onVote(Array.from(selected));
+ } else {
+ onInteractionModal('vote', status);
+ }
+
+ onVote(Array.from(selected));
+ }, [disabled, onVote, selected, signedIn, onInteractionModal, status]);
+
+ useEffect(() => {
+ if (expired || !expires_at) return () => undefined;
+
+ const delay = new Date(expires_at).getTime() - Date.now();
+ const timer = setTimeout(() => {
+ setExpired(true);
+ }, delay);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [expired, expires_at]);
+
+ const timeRemaining =
+ expired || !expires_at ? (
+ intl.formatMessage(messages.closed)
+ ) : (
+
+ );
+ const showResults = poll.voted || revealed || expired;
+
+ let votesCount = null;
+
+ if (poll.voters_count) {
+ votesCount = (
+
+ );
+ } else {
+ votesCount = (
+
+ );
+ }
+
+ return (
+
+
+ {poll.options.map((option, i) => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want `votes_count` if `voters_count` is 0
+ const pollVotesCount = poll.voters_count || poll.votes_count;
+ const percent =
+ pollVotesCount === 0
+ ? 0
+ : (option.votes_count / pollVotesCount) * 100;
+
+ return (
+ other.title === option.title)
+ .every((other) => option.votes_count >= other.votes_count)}
+ percent={percent}
+ disabled={disabled || selected.size === 0}
+ />
+ );
+ })}
+
+
+
+ {!showResults && (
+
+ )}
+ {!showResults && (
+ <>
+ {' '}
+ ·{' '}
+ >
+ )}
+ {showResults && !disabled && (
+ <>
+ {' '}
+ ·{' '}
+ >
+ )}
+ {votesCount}
+ {poll.expires_at && <> · {timeRemaining}>}
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/ui/util/optional_motion.js b/app/javascript/mastodon/features/ui/util/optional_motion.js
index 0b6d4d97f7..5d29eb45f6 100644
--- a/app/javascript/mastodon/features/ui/util/optional_motion.js
+++ b/app/javascript/mastodon/features/ui/util/optional_motion.js
@@ -1,4 +1,4 @@
-import Motion from 'react-motion/lib/Motion';
+import { Motion } from 'react-motion';
import { reduceMotion } from '../../../initial_state';
diff --git a/app/javascript/mastodon/features/ui/util/reduced_motion.jsx b/app/javascript/mastodon/features/ui/util/reduced_motion.jsx
index fd044497f8..52add28945 100644
--- a/app/javascript/mastodon/features/ui/util/reduced_motion.jsx
+++ b/app/javascript/mastodon/features/ui/util/reduced_motion.jsx
@@ -3,7 +3,7 @@
import PropTypes from 'prop-types';
import { Component } from 'react';
-import Motion from 'react-motion/lib/Motion';
+import { Motion } from 'react-motion';
const stylesToKeep = ['opacity', 'backgroundOpacity'];