Convert the polls reducer to plain JS ()

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
app/javascript/mastodon
actions
api_types
components
containers
models
reducers

View file

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

View file

@ -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])],
}),
);
},

View file

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

View file

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

View file

@ -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<PollOptionTranslationShape>;
export const PollOptionTranslationFactory = Record<PollOptionTranslationShape>({
title: '',
titleHtml: '',
});
interface PollOptionShape extends Required<ApiPollOptionJSON> {
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<PollOptionShape>;
export const PollOptionFactory = Record<PollOptionShape>({
title: '',
votes_count: 0,
voted: false,
titleHtml: '',
translation: null,
});
interface PollShape
export interface Poll
extends Omit<ApiPollJSON, 'emojis' | 'options' | 'own_votes'> {
emojis: List<CustomEmoji>;
options: List<PollOption>;
own_votes?: List<number>;
emojis: CustomEmoji[];
options: PollOption[];
own_votes?: number[];
}
export type Poll = RecordOf<PollShape>;
export const PollFactory = Record<PollShape>({
id: '',
expires_at: '',
const pollDefaultValues = {
expired: false,
multiple: false,
voters_count: 0,
votes_count: 0,
voted: false,
emojis: List<CustomEmoji>(),
options: List<PollOption>(),
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;
}

View file

@ -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<string, Poll>();
const initialState: Record<string, Poll> = {};
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<PollsState> = (
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;
};