diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js
index 047cf11910..a527043940 100644
--- a/app/javascript/mastodon/actions/importer/index.js
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -71,7 +71,7 @@ export function importFetchedStatuses(statuses) {
}
if (status.poll?.id) {
- pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id)));
+ pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id]));
}
if (status.card) {
diff --git a/app/javascript/mastodon/actions/polls.ts b/app/javascript/mastodon/actions/polls.ts
index 28f729394b..65a96e8f62 100644
--- a/app/javascript/mastodon/actions/polls.ts
+++ b/app/javascript/mastodon/actions/polls.ts
@@ -15,7 +15,7 @@ export const importFetchedPoll = createAppAsyncThunk(
dispatch(
importPolls({
- polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))],
+ polls: [createPollFromServerJSON(poll, getState().polls[poll.id])],
}),
);
},
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..e78f106d3c
--- /dev/null
+++ b/app/javascript/mastodon/components/poll.tsx
@@ -0,0 +1,331 @@
+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);
+ }
+ }, [disabled, onVote, selected, signedIn, status, onInteractionModal]);
+
+ 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/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx
index d18602e3b5..6d6a46131a 100644
--- a/app/javascript/mastodon/containers/media_container.jsx
+++ b/app/javascript/mastodon/containers/media_container.jsx
@@ -7,12 +7,13 @@ 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';
import Video from 'mastodon/features/video';
import { IntlProvider } from 'mastodon/locales';
+import { createPollFromServerJSON } from 'mastodon/models/poll';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
@@ -88,7 +89,7 @@ export default class MediaContainer extends PureComponent {
Object.assign(props, {
...(media ? { media: fromJS(media) } : {}),
...(card ? { card: fromJS(card) } : {}),
- ...(poll ? { poll: fromJS(poll) } : {}),
+ ...(poll ? { poll: createPollFromServerJSON(poll) } : {}),
...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
...(componentName === 'Video' ? {
diff --git a/app/javascript/mastodon/containers/poll_container.js b/app/javascript/mastodon/containers/poll_container.js
index 7ca840138d..4003fb192c 100644
--- a/app/javascript/mastodon/containers/poll_container.js
+++ b/app/javascript/mastodon/containers/poll_container.js
@@ -4,7 +4,7 @@ import { debounce } from 'lodash';
import { openModal } from 'mastodon/actions/modal';
import { fetchPoll, vote } from 'mastodon/actions/polls';
-import Poll from 'mastodon/components/poll';
+import { Poll } from 'mastodon/components/poll';
const mapDispatchToProps = (dispatch, { pollId }) => ({
refresh: debounce(
@@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { pollId }) => ({
});
const mapStateToProps = (state, { pollId }) => ({
- poll: state.polls.get(pollId),
+ poll: state.polls[pollId],
});
export default connect(mapStateToProps, mapDispatchToProps)(Poll);
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'];
diff --git a/app/javascript/mastodon/models/poll.ts b/app/javascript/mastodon/models/poll.ts
index b4ba38a9c6..6f5655680d 100644
--- a/app/javascript/mastodon/models/poll.ts
+++ b/app/javascript/mastodon/models/poll.ts
@@ -1,6 +1,3 @@
-import type { RecordOf } from 'immutable';
-import { Record, List } from 'immutable';
-
import escapeTextContentForBrowser from 'escape-html';
import type { ApiPollJSON, ApiPollOptionJSON } from 'mastodon/api_types/polls';
@@ -9,19 +6,12 @@ import emojify from 'mastodon/features/emoji/emoji';
import { CustomEmojiFactory, makeEmojiMap } from './custom_emoji';
import type { CustomEmoji, EmojiMap } from './custom_emoji';
-interface PollOptionTranslationShape {
+interface PollOptionTranslation {
title: string;
titleHtml: string;
}
-export type PollOptionTranslation = RecordOf;
-
-export const PollOptionTranslationFactory = Record({
- title: '',
- titleHtml: '',
-});
-
-interface PollOptionShape extends Required {
+export interface PollOption extends ApiPollOptionJSON {
voted: boolean;
titleHtml: string;
translation: PollOptionTranslation | null;
@@ -31,45 +21,30 @@ export function createPollOptionTranslationFromServerJSON(
translation: { title: string },
emojiMap: EmojiMap,
) {
- return PollOptionTranslationFactory({
+ return {
...translation,
titleHtml: emojify(
escapeTextContentForBrowser(translation.title),
emojiMap,
),
- });
+ } as PollOptionTranslation;
}
-export type PollOption = RecordOf;
-
-export const PollOptionFactory = Record({
- title: '',
- votes_count: 0,
- voted: false,
- titleHtml: '',
- translation: null,
-});
-
-interface PollShape
+export interface Poll
extends Omit {
- emojis: List;
- options: List;
- own_votes?: List;
+ emojis: CustomEmoji[];
+ options: PollOption[];
+ own_votes?: number[];
}
-export type Poll = RecordOf;
-export const PollFactory = Record({
- id: '',
- expires_at: '',
+const pollDefaultValues = {
expired: false,
multiple: false,
voters_count: 0,
votes_count: 0,
voted: false,
- emojis: List(),
- options: List(),
- own_votes: List(),
-});
+ own_votes: [],
+};
export function createPollFromServerJSON(
serverJSON: ApiPollJSON,
@@ -77,33 +52,31 @@ export function createPollFromServerJSON(
) {
const emojiMap = makeEmojiMap(serverJSON.emojis);
- return PollFactory({
+ return {
+ ...pollDefaultValues,
...serverJSON,
- emojis: List(serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji))),
- own_votes: serverJSON.own_votes ? List(serverJSON.own_votes) : undefined,
- options: List(
- serverJSON.options.map((optionJSON, index) => {
- const option = PollOptionFactory({
- ...optionJSON,
- voted: serverJSON.own_votes?.includes(index) || false,
- titleHtml: emojify(
- escapeTextContentForBrowser(optionJSON.title),
- emojiMap,
- ),
- });
+ emojis: serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji)),
+ options: serverJSON.options.map((optionJSON, index) => {
+ const option = {
+ ...optionJSON,
+ voted: serverJSON.own_votes?.includes(index) || false,
+ titleHtml: emojify(
+ escapeTextContentForBrowser(optionJSON.title),
+ emojiMap,
+ ),
+ } as PollOption;
- const prevOption = previousPoll?.options.get(index);
- if (prevOption?.translation && prevOption.title === option.title) {
- const { translation } = prevOption;
+ const prevOption = previousPoll?.options[index];
+ if (prevOption?.translation && prevOption.title === option.title) {
+ const { translation } = prevOption;
- option.set(
- 'translation',
- createPollOptionTranslationFromServerJSON(translation, emojiMap),
- );
- }
+ option.translation = createPollOptionTranslationFromServerJSON(
+ translation,
+ emojiMap,
+ );
+ }
- return option;
- }),
- ),
- });
+ return option;
+ }),
+ } as Poll;
}
diff --git a/app/javascript/mastodon/reducers/polls.ts b/app/javascript/mastodon/reducers/polls.ts
index 9b9a5d2ff8..aadf6741c1 100644
--- a/app/javascript/mastodon/reducers/polls.ts
+++ b/app/javascript/mastodon/reducers/polls.ts
@@ -1,5 +1,4 @@
import type { Reducer } from '@reduxjs/toolkit';
-import { Map as ImmutableMap } from 'immutable';
import { importPolls } from 'mastodon/actions/importer/polls';
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
@@ -11,57 +10,48 @@ import {
STATUS_TRANSLATE_UNDO,
} from '../actions/statuses';
-const initialState = ImmutableMap();
+const initialState: Record = {};
type PollsState = typeof initialState;
-const statusTranslateSuccess = (
- state: PollsState,
- pollTranslation: Poll | undefined,
-) => {
- if (!pollTranslation) return state;
+const statusTranslateSuccess = (state: PollsState, pollTranslation?: Poll) => {
+ if (!pollTranslation) return;
- return state.withMutations((map) => {
- const poll = state.get(pollTranslation.id);
+ const poll = state[pollTranslation.id];
- if (!poll) return;
+ if (!poll) return;
- const emojiMap = makeEmojiMap(poll.emojis);
+ const emojiMap = makeEmojiMap(poll.emojis);
- pollTranslation.options.forEach((item, index) => {
- map.setIn(
- [pollTranslation.id, 'options', index, 'translation'],
- createPollOptionTranslationFromServerJSON(item, emojiMap),
- );
- });
+ pollTranslation.options.forEach((item, index) => {
+ const option = poll.options[index];
+ if (!option) return;
+
+ option.translation = createPollOptionTranslationFromServerJSON(
+ item,
+ emojiMap,
+ );
});
};
const statusTranslateUndo = (state: PollsState, id: string) => {
- return state.withMutations((map) => {
- const options = map.get(id)?.options;
-
- if (options) {
- options.forEach((item, index) =>
- map.deleteIn([id, 'options', index, 'translation']),
- );
- }
+ state[id]?.options.forEach((option) => {
+ option.translation = null;
});
};
export const pollsReducer: Reducer = (
- state = initialState,
+ draft = initialState,
action,
) => {
if (importPolls.match(action)) {
- return state.withMutations((polls) => {
- action.payload.polls.forEach((poll) => polls.set(poll.id, poll));
+ action.payload.polls.forEach((poll) => {
+ draft[poll.id] = poll;
});
} else if (action.type === STATUS_TRANSLATE_SUCCESS)
- return statusTranslateSuccess(
- state,
- (action.translation as { poll?: Poll }).poll,
- );
- else if (action.type === STATUS_TRANSLATE_UNDO)
- return statusTranslateUndo(state, action.pollId as string);
- else return state;
+ statusTranslateSuccess(draft, (action.translation as { poll?: Poll }).poll);
+ else if (action.type === STATUS_TRANSLATE_UNDO) {
+ statusTranslateUndo(draft, action.pollId as string);
+ }
+
+ return draft;
};