diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 047cf11910c..a527043940f 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 28f729394b0..65a96e8f622 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 275ca29fd72..891a2faba79 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: number | null; options: ApiPollOptionJSON[]; emojis: ApiCustomEmojiJSON[]; diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx index 48f4214c261..c3ba7c97efb 100644 --- a/app/javascript/mastodon/components/poll.tsx +++ b/app/javascript/mastodon/components/poll.tsx @@ -49,7 +49,7 @@ export const Poll: React.FC = (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 = (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 = (props) => { if (expired) { return intl.formatMessage(messages.closed); } - return ; + return ; }, [expired, intl, poll]); const votesCount = useMemo(() => { if (!poll) { return null; } - if (poll.get('voters_count')) { + if (poll.voters_count) { return ( ); } @@ -92,7 +92,7 @@ export const Poll: React.FC = (props) => { ); }, [poll]); @@ -144,7 +144,7 @@ export const Poll: React.FC = (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 = (props) => { if (!poll) { return null; } - const showResults = poll.get('voted') || revealed || expired; + const showResults = poll.voted || revealed || expired; return (
    - {poll.get('options').map((option, i) => ( + {poll.options.map((option, i) => ( = (props) => { )} {votesCount} - {poll.get('expires_at') && <> · {timeRemaining}} + {poll.expires_at && <> · {timeRemaining}}
); @@ -222,36 +222,30 @@ type PollOptionProps = Pick & { const PollOption: React.FC = (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 = (props) => { > = (props) => { {!showResults && ( = (props) => { {Math.round(percent)}% diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx index df162730cf2..9c07341faad 100644 --- a/app/javascript/mastodon/containers/media_container.jsx +++ b/app/javascript/mastodon/containers/media_container.jsx @@ -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' ? { diff --git a/app/javascript/mastodon/models/poll.ts b/app/javascript/mastodon/models/poll.ts index b4ba38a9c6f..6f5655680d6 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 9b9a5d2ff8e..aadf6741c13 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; };