Convert the polls reducer to plain JS (#33263)

This commit is contained in:
Renaud Chaput 2025-03-29 21:17:27 +01:00 committed by GitHub
parent 04a9252a93
commit 1bc28709cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 89 additions and 131 deletions

View file

@ -71,7 +71,7 @@ export function importFetchedStatuses(statuses) {
} }
if (status.poll?.id) { 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) { if (status.card) {

View file

@ -15,7 +15,7 @@ export const importFetchedPoll = createAppAsyncThunk(
dispatch( dispatch(
importPolls({ importPolls({
polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))], polls: [createPollFromServerJSON(poll, getState().polls[poll.id])],
}), }),
); );
}, },

View file

@ -13,7 +13,7 @@ export interface ApiPollJSON {
expired: boolean; expired: boolean;
multiple: boolean; multiple: boolean;
votes_count: number; votes_count: number;
voters_count: number; voters_count: number | null;
options: ApiPollOptionJSON[]; options: ApiPollOptionJSON[];
emojis: ApiCustomEmojiJSON[]; emojis: ApiCustomEmojiJSON[];

View file

@ -49,7 +49,7 @@ export const Poll: React.FC<PollProps> = (props) => {
const { pollId, status } = props; const { pollId, status } = props;
// Third party hooks // Third party hooks
const poll = useAppSelector((state) => state.polls.get(pollId)); const poll = useAppSelector((state) => state.polls[pollId]);
const identity = useIdentity(); const identity = useIdentity();
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -63,8 +63,8 @@ export const Poll: React.FC<PollProps> = (props) => {
if (!poll) { if (!poll) {
return false; return false;
} }
const expiresAt = poll.get('expires_at'); const expiresAt = poll.expires_at;
return poll.get('expired') || new Date(expiresAt).getTime() < Date.now(); return poll.expired || new Date(expiresAt).getTime() < Date.now();
}, [poll]); }, [poll]);
const timeRemaining = useMemo(() => { const timeRemaining = useMemo(() => {
if (!poll) { if (!poll) {
@ -73,18 +73,18 @@ export const Poll: React.FC<PollProps> = (props) => {
if (expired) { if (expired) {
return intl.formatMessage(messages.closed); return intl.formatMessage(messages.closed);
} }
return <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; return <RelativeTimestamp timestamp={poll.expires_at} futureDate />;
}, [expired, intl, poll]); }, [expired, intl, poll]);
const votesCount = useMemo(() => { const votesCount = useMemo(() => {
if (!poll) { if (!poll) {
return null; return null;
} }
if (poll.get('voters_count')) { if (poll.voters_count) {
return ( return (
<FormattedMessage <FormattedMessage
id='poll.total_people' id='poll.total_people'
defaultMessage='{count, plural, one {# person} other {# people}}' defaultMessage='{count, plural, one {# person} other {# people}}'
values={{ count: poll.get('voters_count') }} values={{ count: poll.voters_count }}
/> />
); );
} }
@ -92,7 +92,7 @@ export const Poll: React.FC<PollProps> = (props) => {
<FormattedMessage <FormattedMessage
id='poll.total_votes' id='poll.total_votes'
defaultMessage='{count, plural, one {# vote} other {# votes}}' defaultMessage='{count, plural, one {# vote} other {# votes}}'
values={{ count: poll.get('votes_count') }} values={{ count: poll.votes_count }}
/> />
); );
}, [poll]); }, [poll]);
@ -144,7 +144,7 @@ export const Poll: React.FC<PollProps> = (props) => {
if (!poll) { if (!poll) {
return; return;
} }
if (poll.get('multiple')) { if (poll.multiple) {
setSelected((prev) => ({ setSelected((prev) => ({
...prev, ...prev,
[choiceIndex]: !prev[choiceIndex], [choiceIndex]: !prev[choiceIndex],
@ -159,14 +159,14 @@ export const Poll: React.FC<PollProps> = (props) => {
if (!poll) { if (!poll) {
return null; return null;
} }
const showResults = poll.get('voted') || revealed || expired; const showResults = poll.voted || revealed || expired;
return ( return (
<div className='poll'> <div className='poll'>
<ul> <ul>
{poll.get('options').map((option, i) => ( {poll.options.map((option, i) => (
<PollOption <PollOption
key={option.get('title') || i} key={option.title || i}
index={i} index={i}
poll={poll} poll={poll}
option={option} option={option}
@ -204,7 +204,7 @@ export const Poll: React.FC<PollProps> = (props) => {
</> </>
)} )}
{votesCount} {votesCount}
{poll.get('expires_at') && <> · {timeRemaining}</>} {poll.expires_at && <> · {timeRemaining}</>}
</div> </div>
</div> </div>
); );
@ -222,36 +222,30 @@ type PollOptionProps = Pick<PollProps, 'disabled' | 'lang'> & {
const PollOption: React.FC<PollOptionProps> = (props) => { const PollOption: React.FC<PollOptionProps> = (props) => {
const { active, lang, disabled, poll, option, index, showResults, onChange } = const { active, lang, disabled, poll, option, index, showResults, onChange } =
props; props;
const voted = option.get('voted') || poll.get('own_votes')?.includes(index); const voted = option.voted || poll.own_votes?.includes(index);
const title = const title = option.translation?.title ?? option.title;
(option.getIn(['translation', 'title']) as string) || option.get('title');
const intl = useIntl(); const intl = useIntl();
// Derived values // Derived values
const percent = useMemo(() => { const percent = useMemo(() => {
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); const pollVotesCount = poll.voters_count ?? poll.votes_count;
return pollVotesCount === 0 return pollVotesCount === 0
? 0 ? 0
: (option.get('votes_count') / pollVotesCount) * 100; : (option.votes_count / pollVotesCount) * 100;
}, [option, poll]); }, [option, poll]);
const isLeading = useMemo( const isLeading = useMemo(
() => () =>
poll poll.options
.get('options') .filter((other) => other.title !== option.title)
.filterNot((other) => other.get('title') === option.get('title')) .every((other) => option.votes_count >= other.votes_count),
.every(
(other) => option.get('votes_count') >= other.get('votes_count'),
),
[poll, option], [poll, option],
); );
const titleHtml = useMemo(() => { const titleHtml = useMemo(() => {
let titleHtml = let titleHtml = option.translation?.titleHtml ?? option.titleHtml;
(option.getIn(['translation', 'titleHtml']) as string) ||
option.get('titleHtml');
if (!titleHtml) { if (!titleHtml) {
const emojiMap = makeEmojiMap(poll.get('emojis')); const emojiMap = makeEmojiMap(poll.emojis);
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
} }
@ -290,7 +284,7 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
> >
<input <input
name='vote-options' name='vote-options'
type={poll.get('multiple') ? 'checkbox' : 'radio'} type={poll.multiple ? 'checkbox' : 'radio'}
value={index} value={index}
checked={active} checked={active}
onChange={handleOptionChange} onChange={handleOptionChange}
@ -300,11 +294,11 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
{!showResults && ( {!showResults && (
<span <span
className={classNames('poll__input', { className={classNames('poll__input', {
checkbox: poll.get('multiple'), checkbox: poll.multiple,
active, active,
})} })}
tabIndex={0} tabIndex={0}
role={poll.get('multiple') ? 'checkbox' : 'radio'} role={poll.multiple ? 'checkbox' : 'radio'}
onKeyDown={handleOptionKeyPress} onKeyDown={handleOptionKeyPress}
aria-checked={active} aria-checked={active}
aria-label={title} aria-label={title}
@ -316,7 +310,7 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
<span <span
className='poll__number' className='poll__number'
title={intl.formatMessage(messages.votes, { title={intl.formatMessage(messages.votes, {
votes: option.get('votes_count'), votes: option.votes_count,
})} })}
> >
{Math.round(percent)}% {Math.round(percent)}%

View file

@ -13,6 +13,7 @@ import Card from 'mastodon/features/status/components/card';
import MediaModal from 'mastodon/features/ui/components/media_modal'; import MediaModal from 'mastodon/features/ui/components/media_modal';
import { Video } from 'mastodon/features/video'; import { Video } from 'mastodon/features/video';
import { IntlProvider } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
import { createPollFromServerJSON } from 'mastodon/models/poll';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
@ -88,7 +89,7 @@ export default class MediaContainer extends PureComponent {
Object.assign(props, { Object.assign(props, {
...(media ? { media: fromJS(media) } : {}), ...(media ? { media: fromJS(media) } : {}),
...(card ? { card: fromJS(card) } : {}), ...(card ? { card: fromJS(card) } : {}),
...(poll ? { poll: fromJS(poll) } : {}), ...(poll ? { poll: createPollFromServerJSON(poll) } : {}),
...(hashtag ? { hashtag: fromJS(hashtag) } : {}), ...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
...(componentName === 'Video' ? { ...(componentName === 'Video' ? {

View file

@ -1,6 +1,3 @@
import type { RecordOf } from 'immutable';
import { Record, List } from 'immutable';
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
import type { ApiPollJSON, ApiPollOptionJSON } from 'mastodon/api_types/polls'; 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 { CustomEmojiFactory, makeEmojiMap } from './custom_emoji';
import type { CustomEmoji, EmojiMap } from './custom_emoji'; import type { CustomEmoji, EmojiMap } from './custom_emoji';
interface PollOptionTranslationShape { interface PollOptionTranslation {
title: string; title: string;
titleHtml: string; titleHtml: string;
} }
export type PollOptionTranslation = RecordOf<PollOptionTranslationShape>; export interface PollOption extends ApiPollOptionJSON {
export const PollOptionTranslationFactory = Record<PollOptionTranslationShape>({
title: '',
titleHtml: '',
});
interface PollOptionShape extends Required<ApiPollOptionJSON> {
voted: boolean; voted: boolean;
titleHtml: string; titleHtml: string;
translation: PollOptionTranslation | null; translation: PollOptionTranslation | null;
@ -31,45 +21,30 @@ export function createPollOptionTranslationFromServerJSON(
translation: { title: string }, translation: { title: string },
emojiMap: EmojiMap, emojiMap: EmojiMap,
) { ) {
return PollOptionTranslationFactory({ return {
...translation, ...translation,
titleHtml: emojify( titleHtml: emojify(
escapeTextContentForBrowser(translation.title), escapeTextContentForBrowser(translation.title),
emojiMap, emojiMap,
), ),
}); } as PollOptionTranslation;
} }
export type PollOption = RecordOf<PollOptionShape>; export interface Poll
export const PollOptionFactory = Record<PollOptionShape>({
title: '',
votes_count: 0,
voted: false,
titleHtml: '',
translation: null,
});
interface PollShape
extends Omit<ApiPollJSON, 'emojis' | 'options' | 'own_votes'> { extends Omit<ApiPollJSON, 'emojis' | 'options' | 'own_votes'> {
emojis: List<CustomEmoji>; emojis: CustomEmoji[];
options: List<PollOption>; options: PollOption[];
own_votes?: List<number>; own_votes?: number[];
} }
export type Poll = RecordOf<PollShape>;
export const PollFactory = Record<PollShape>({ const pollDefaultValues = {
id: '',
expires_at: '',
expired: false, expired: false,
multiple: false, multiple: false,
voters_count: 0, voters_count: 0,
votes_count: 0, votes_count: 0,
voted: false, voted: false,
emojis: List<CustomEmoji>(), own_votes: [],
options: List<PollOption>(), };
own_votes: List(),
});
export function createPollFromServerJSON( export function createPollFromServerJSON(
serverJSON: ApiPollJSON, serverJSON: ApiPollJSON,
@ -77,33 +52,31 @@ export function createPollFromServerJSON(
) { ) {
const emojiMap = makeEmojiMap(serverJSON.emojis); const emojiMap = makeEmojiMap(serverJSON.emojis);
return PollFactory({ return {
...pollDefaultValues,
...serverJSON, ...serverJSON,
emojis: List(serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji))), emojis: serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji)),
own_votes: serverJSON.own_votes ? List(serverJSON.own_votes) : undefined, options: serverJSON.options.map((optionJSON, index) => {
options: List( const option = {
serverJSON.options.map((optionJSON, index) => { ...optionJSON,
const option = PollOptionFactory({ voted: serverJSON.own_votes?.includes(index) || false,
...optionJSON, titleHtml: emojify(
voted: serverJSON.own_votes?.includes(index) || false, escapeTextContentForBrowser(optionJSON.title),
titleHtml: emojify( emojiMap,
escapeTextContentForBrowser(optionJSON.title), ),
emojiMap, } as PollOption;
),
});
const prevOption = previousPoll?.options.get(index); const prevOption = previousPoll?.options[index];
if (prevOption?.translation && prevOption.title === option.title) { if (prevOption?.translation && prevOption.title === option.title) {
const { translation } = prevOption; const { translation } = prevOption;
option.set( option.translation = createPollOptionTranslationFromServerJSON(
'translation', translation,
createPollOptionTranslationFromServerJSON(translation, emojiMap), emojiMap,
); );
} }
return option; return option;
}), }),
), } as Poll;
});
} }

View file

@ -1,5 +1,4 @@
import type { Reducer } from '@reduxjs/toolkit'; import type { Reducer } from '@reduxjs/toolkit';
import { Map as ImmutableMap } from 'immutable';
import { importPolls } from 'mastodon/actions/importer/polls'; import { importPolls } from 'mastodon/actions/importer/polls';
import { makeEmojiMap } from 'mastodon/models/custom_emoji'; import { makeEmojiMap } from 'mastodon/models/custom_emoji';
@ -11,57 +10,48 @@ import {
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
} from '../actions/statuses'; } from '../actions/statuses';
const initialState = ImmutableMap<string, Poll>(); const initialState: Record<string, Poll> = {};
type PollsState = typeof initialState; type PollsState = typeof initialState;
const statusTranslateSuccess = ( const statusTranslateSuccess = (state: PollsState, pollTranslation?: Poll) => {
state: PollsState, if (!pollTranslation) return;
pollTranslation: Poll | undefined,
) => {
if (!pollTranslation) return state;
return state.withMutations((map) => { const poll = state[pollTranslation.id];
const poll = state.get(pollTranslation.id);
if (!poll) return; if (!poll) return;
const emojiMap = makeEmojiMap(poll.emojis); const emojiMap = makeEmojiMap(poll.emojis);
pollTranslation.options.forEach((item, index) => { pollTranslation.options.forEach((item, index) => {
map.setIn( const option = poll.options[index];
[pollTranslation.id, 'options', index, 'translation'], if (!option) return;
createPollOptionTranslationFromServerJSON(item, emojiMap),
); option.translation = createPollOptionTranslationFromServerJSON(
}); item,
emojiMap,
);
}); });
}; };
const statusTranslateUndo = (state: PollsState, id: string) => { const statusTranslateUndo = (state: PollsState, id: string) => {
return state.withMutations((map) => { state[id]?.options.forEach((option) => {
const options = map.get(id)?.options; option.translation = null;
if (options) {
options.forEach((item, index) =>
map.deleteIn([id, 'options', index, 'translation']),
);
}
}); });
}; };
export const pollsReducer: Reducer<PollsState> = ( export const pollsReducer: Reducer<PollsState> = (
state = initialState, draft = initialState,
action, action,
) => { ) => {
if (importPolls.match(action)) { if (importPolls.match(action)) {
return state.withMutations((polls) => { action.payload.polls.forEach((poll) => {
action.payload.polls.forEach((poll) => polls.set(poll.id, poll)); draft[poll.id] = poll;
}); });
} else if (action.type === STATUS_TRANSLATE_SUCCESS) } else if (action.type === STATUS_TRANSLATE_SUCCESS)
return statusTranslateSuccess( statusTranslateSuccess(draft, (action.translation as { poll?: Poll }).poll);
state, else if (action.type === STATUS_TRANSLATE_UNDO) {
(action.translation as { poll?: Poll }).poll, statusTranslateUndo(draft, action.pollId as string);
); }
else if (action.type === STATUS_TRANSLATE_UNDO)
return statusTranslateUndo(state, action.pollId as string); return draft;
else return state;
}; };