Convert the polls reducer to plain JS (#33263)
This commit is contained in:
		
					parent
					
						
							
								04a9252a93
							
						
					
				
			
			
				commit
				
					
						1bc28709cc
					
				
			
		
					 7 changed files with 89 additions and 131 deletions
				
			
		|  | @ -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) { | ||||
|  |  | |||
|  | @ -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])], | ||||
|       }), | ||||
|     ); | ||||
|   }, | ||||
|  |  | |||
|  | @ -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[]; | ||||
|  |  | |||
|  | @ -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)}% | ||||
|  |  | |||
|  | @ -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' ? { | ||||
|  |  | |||
|  | @ -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({ | ||||
|     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); | ||||
|       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; | ||||
|     }), | ||||
|     ), | ||||
|   }); | ||||
|   } as Poll; | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
| 
 | ||||
|   const emojiMap = makeEmojiMap(poll.emojis); | ||||
| 
 | ||||
|   pollTranslation.options.forEach((item, index) => { | ||||
|       map.setIn( | ||||
|         [pollTranslation.id, 'options', index, 'translation'], | ||||
|         createPollOptionTranslationFromServerJSON(item, emojiMap), | ||||
|     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; | ||||
| }; | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue