From ded799f91302c1ea2ac0b463ef50e309e154466c Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Tue, 10 Dec 2024 23:54:07 +0100 Subject: [PATCH] Convert polls to Typescript / Immutable Records (#29789) --- .../mastodon/actions/importer/index.js | 20 +--- .../mastodon/actions/importer/normalizer.js | 39 +------ .../mastodon/actions/importer/polls.ts | 7 ++ app/javascript/mastodon/actions/polls.js | 61 ---------- app/javascript/mastodon/actions/polls.ts | 40 +++++++ app/javascript/mastodon/api/polls.ts | 10 ++ app/javascript/mastodon/api_types/polls.ts | 4 +- app/javascript/mastodon/components/poll.jsx | 9 +- .../mastodon/containers/poll_container.js | 6 +- app/javascript/mastodon/models/account.ts | 14 +-- .../mastodon/models/custom_emoji.ts | 23 +++- app/javascript/mastodon/models/poll.ts | 109 ++++++++++++++++++ app/javascript/mastodon/reducers/index.ts | 4 +- app/javascript/mastodon/reducers/polls.js | 45 -------- app/javascript/mastodon/reducers/polls.ts | 67 +++++++++++ 15 files changed, 272 insertions(+), 186 deletions(-) create mode 100644 app/javascript/mastodon/actions/importer/polls.ts delete mode 100644 app/javascript/mastodon/actions/polls.js create mode 100644 app/javascript/mastodon/actions/polls.ts create mode 100644 app/javascript/mastodon/api/polls.ts create mode 100644 app/javascript/mastodon/models/poll.ts delete mode 100644 app/javascript/mastodon/reducers/polls.js create mode 100644 app/javascript/mastodon/reducers/polls.ts diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 516a7a7973..047cf11910 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -1,10 +1,12 @@ +import { createPollFromServerJSON } from 'mastodon/models/poll'; + import { importAccounts } from '../accounts_typed'; -import { normalizeStatus, normalizePoll } from './normalizer'; +import { normalizeStatus } from './normalizer'; +import { importPolls } from './polls'; export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; -export const POLLS_IMPORT = 'POLLS_IMPORT'; export const FILTERS_IMPORT = 'FILTERS_IMPORT'; function pushUnique(array, object) { @@ -25,10 +27,6 @@ export function importFilters(filters) { return { type: FILTERS_IMPORT, filters }; } -export function importPolls(polls) { - return { type: POLLS_IMPORT, polls }; -} - export function importFetchedAccount(account) { return importFetchedAccounts([account]); } @@ -73,7 +71,7 @@ export function importFetchedStatuses(statuses) { } if (status.poll?.id) { - pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); + pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id))); } if (status.card) { @@ -83,15 +81,9 @@ export function importFetchedStatuses(statuses) { statuses.forEach(processStatus); - dispatch(importPolls(polls)); + dispatch(importPolls({ polls })); dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); dispatch(importFilters(filters)); }; } - -export function importFetchedPoll(poll) { - return (dispatch, getState) => { - dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))])); - }; -} diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index c09a3f442c..c2918ef8d5 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -1,15 +1,12 @@ import escapeTextContentForBrowser from 'escape-html'; +import { makeEmojiMap } from 'mastodon/models/custom_emoji'; + import emojify from '../../features/emoji/emoji'; import { expandSpoilers } from '../../initial_state'; const domParser = new DOMParser(); -const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => { - obj[`:${emoji.shortcode}:`] = emoji; - return obj; -}, {}); - export function searchTextFromRawStatus (status) { const spoilerText = status.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); @@ -112,38 +109,6 @@ export function normalizeStatusTranslation(translation, status) { return normalTranslation; } -export function normalizePoll(poll, normalOldPoll) { - const normalPoll = { ...poll }; - const emojiMap = makeEmojiMap(poll.emojis); - - normalPoll.options = poll.options.map((option, index) => { - const normalOption = { - ...option, - voted: poll.own_votes && poll.own_votes.includes(index), - titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), - }; - - if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) { - normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']); - } - - return normalOption; - }); - - return normalPoll; -} - -export function normalizePollOptionTranslation(translation, poll) { - const emojiMap = makeEmojiMap(poll.get('emojis').toJS()); - - const normalTranslation = { - ...translation, - titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap), - }; - - return normalTranslation; -} - export function normalizeAnnouncement(announcement) { const normalAnnouncement = { ...announcement }; const emojiMap = makeEmojiMap(normalAnnouncement.emojis); diff --git a/app/javascript/mastodon/actions/importer/polls.ts b/app/javascript/mastodon/actions/importer/polls.ts new file mode 100644 index 0000000000..5bbe7d57d6 --- /dev/null +++ b/app/javascript/mastodon/actions/importer/polls.ts @@ -0,0 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; + +import type { Poll } from 'mastodon/models/poll'; + +export const importPolls = createAction<{ polls: Poll[] }>( + 'poll/importMultiple', +); diff --git a/app/javascript/mastodon/actions/polls.js b/app/javascript/mastodon/actions/polls.js deleted file mode 100644 index aa49341444..0000000000 --- a/app/javascript/mastodon/actions/polls.js +++ /dev/null @@ -1,61 +0,0 @@ -import api from '../api'; - -import { importFetchedPoll } from './importer'; - -export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; -export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; -export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; - -export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; -export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; -export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; - -export const vote = (pollId, choices) => (dispatch) => { - dispatch(voteRequest()); - - api().post(`/api/v1/polls/${pollId}/votes`, { choices }) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(voteSuccess(data)); - }) - .catch(err => dispatch(voteFail(err))); -}; - -export const fetchPoll = pollId => (dispatch) => { - dispatch(fetchPollRequest()); - - api().get(`/api/v1/polls/${pollId}`) - .then(({ data }) => { - dispatch(importFetchedPoll(data)); - dispatch(fetchPollSuccess(data)); - }) - .catch(err => dispatch(fetchPollFail(err))); -}; - -export const voteRequest = () => ({ - type: POLL_VOTE_REQUEST, -}); - -export const voteSuccess = poll => ({ - type: POLL_VOTE_SUCCESS, - poll, -}); - -export const voteFail = error => ({ - type: POLL_VOTE_FAIL, - error, -}); - -export const fetchPollRequest = () => ({ - type: POLL_FETCH_REQUEST, -}); - -export const fetchPollSuccess = poll => ({ - type: POLL_FETCH_SUCCESS, - poll, -}); - -export const fetchPollFail = error => ({ - type: POLL_FETCH_FAIL, - error, -}); diff --git a/app/javascript/mastodon/actions/polls.ts b/app/javascript/mastodon/actions/polls.ts new file mode 100644 index 0000000000..28f729394b --- /dev/null +++ b/app/javascript/mastodon/actions/polls.ts @@ -0,0 +1,40 @@ +import { apiGetPoll, apiPollVote } from 'mastodon/api/polls'; +import type { ApiPollJSON } from 'mastodon/api_types/polls'; +import { createPollFromServerJSON } from 'mastodon/models/poll'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'mastodon/store/typed_functions'; + +import { importPolls } from './importer/polls'; + +export const importFetchedPoll = createAppAsyncThunk( + 'poll/importFetched', + (args: { poll: ApiPollJSON }, { dispatch, getState }) => { + const { poll } = args; + + dispatch( + importPolls({ + polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))], + }), + ); + }, +); + +export const vote = createDataLoadingThunk( + 'poll/vote', + ({ pollId, choices }: { pollId: string; choices: string[] }) => + apiPollVote(pollId, choices), + async (poll, { dispatch, discardLoadData }) => { + await dispatch(importFetchedPoll({ poll })); + return discardLoadData; + }, +); + +export const fetchPoll = createDataLoadingThunk( + 'poll/fetch', + ({ pollId }: { pollId: string }) => apiGetPoll(pollId), + async (poll, { dispatch }) => { + await dispatch(importFetchedPoll({ poll })); + }, +); diff --git a/app/javascript/mastodon/api/polls.ts b/app/javascript/mastodon/api/polls.ts new file mode 100644 index 0000000000..07cebca735 --- /dev/null +++ b/app/javascript/mastodon/api/polls.ts @@ -0,0 +1,10 @@ +import { apiRequestGet, apiRequestPost } from 'mastodon/api'; +import type { ApiPollJSON } from 'mastodon/api_types/polls'; + +export const apiGetPoll = (pollId: string) => + apiRequestGet(`/v1/polls/${pollId}`); + +export const apiPollVote = (pollId: string, choices: string[]) => + apiRequestPost(`/v1/polls/${pollId}/votes`, { + data: { choices }, + }); diff --git a/app/javascript/mastodon/api_types/polls.ts b/app/javascript/mastodon/api_types/polls.ts index 8181f7b813..275ca29fd7 100644 --- a/app/javascript/mastodon/api_types/polls.ts +++ b/app/javascript/mastodon/api_types/polls.ts @@ -18,6 +18,6 @@ export interface ApiPollJSON { options: ApiPollOptionJSON[]; emojis: ApiCustomEmojiJSON[]; - voted: boolean; - own_votes: number[]; + voted?: boolean; + own_votes?: number[]; } diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx index 06b09f5b35..1326131009 100644 --- a/app/javascript/mastodon/components/poll.jsx +++ b/app/javascript/mastodon/components/poll.jsx @@ -33,15 +33,10 @@ const messages = defineMessages({ }, }); -const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { - obj[`:${emoji.get('shortcode')}:`] = emoji.toJS(); - return obj; -}, {}); - class Poll extends ImmutablePureComponent { static propTypes = { identity: identityContextPropShape, - poll: ImmutablePropTypes.map.isRequired, + poll: ImmutablePropTypes.record.isRequired, status: ImmutablePropTypes.map.isRequired, lang: PropTypes.string, intl: PropTypes.object.isRequired, @@ -150,7 +145,7 @@ class Poll extends ImmutablePureComponent { let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); if (!titleHtml) { - const emojiMap = makeEmojiMap(poll); + const emojiMap = emojiMap(poll); titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); } diff --git a/app/javascript/mastodon/containers/poll_container.js b/app/javascript/mastodon/containers/poll_container.js index db378cba7c..7ca840138d 100644 --- a/app/javascript/mastodon/containers/poll_container.js +++ b/app/javascript/mastodon/containers/poll_container.js @@ -9,14 +9,14 @@ import Poll from 'mastodon/components/poll'; const mapDispatchToProps = (dispatch, { pollId }) => ({ refresh: debounce( () => { - dispatch(fetchPoll(pollId)); + dispatch(fetchPoll({ pollId })); }, 1000, { leading: true }, ), onVote (choices) { - dispatch(vote(pollId, choices)); + dispatch(vote({ pollId, choices })); }, onInteractionModal (type, status) { @@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { pollId }) => ({ }); const mapStateToProps = (state, { pollId }) => ({ - poll: state.getIn(['polls', pollId]), + poll: state.polls.get(pollId), }); export default connect(mapStateToProps, mapDispatchToProps)(Poll); diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index 34fd1b57e9..4d95d24757 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -8,12 +8,11 @@ import type { ApiAccountRoleJSON, ApiAccountJSON, } from 'mastodon/api_types/accounts'; -import type { ApiCustomEmojiJSON } from 'mastodon/api_types/custom_emoji'; import emojify from 'mastodon/features/emoji/emoji'; import { unescapeHTML } from 'mastodon/utils/html'; -import { CustomEmojiFactory } from './custom_emoji'; -import type { CustomEmoji } from './custom_emoji'; +import { CustomEmojiFactory, makeEmojiMap } from './custom_emoji'; +import type { CustomEmoji, EmojiMap } from './custom_emoji'; // AccountField interface AccountFieldShape extends Required { @@ -102,15 +101,6 @@ export const accountDefaultValues: AccountShape = { const AccountFactory = ImmutableRecord(accountDefaultValues); -type EmojiMap = Record; - -function makeEmojiMap(emojis: ApiCustomEmojiJSON[]) { - return emojis.reduce((obj, emoji) => { - obj[`:${emoji.shortcode}:`] = emoji; - return obj; - }, {}); -} - function createAccountField( jsonField: ApiAccountFieldJSON, emojiMap: EmojiMap, diff --git a/app/javascript/mastodon/models/custom_emoji.ts b/app/javascript/mastodon/models/custom_emoji.ts index 76479f3aeb..5297dcd470 100644 --- a/app/javascript/mastodon/models/custom_emoji.ts +++ b/app/javascript/mastodon/models/custom_emoji.ts @@ -1,15 +1,32 @@ -import type { RecordOf } from 'immutable'; -import { Record } from 'immutable'; +import type { RecordOf, List as ImmutableList } from 'immutable'; +import { Record as ImmutableRecord, isList } from 'immutable'; import type { ApiCustomEmojiJSON } from 'mastodon/api_types/custom_emoji'; type CustomEmojiShape = Required; // no changes from server shape export type CustomEmoji = RecordOf; -export const CustomEmojiFactory = Record({ +export const CustomEmojiFactory = ImmutableRecord({ shortcode: '', static_url: '', url: '', category: '', visible_in_picker: false, }); + +export type EmojiMap = Record; + +export function makeEmojiMap( + emojis: ApiCustomEmojiJSON[] | ImmutableList, +) { + if (isList(emojis)) { + return emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji.toJS(); + return obj; + }, {}); + } else + return emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji; + return obj; + }, {}); +} diff --git a/app/javascript/mastodon/models/poll.ts b/app/javascript/mastodon/models/poll.ts new file mode 100644 index 0000000000..b4ba38a9c6 --- /dev/null +++ b/app/javascript/mastodon/models/poll.ts @@ -0,0 +1,109 @@ +import type { RecordOf } from 'immutable'; +import { Record, List } from 'immutable'; + +import escapeTextContentForBrowser from 'escape-html'; + +import type { ApiPollJSON, ApiPollOptionJSON } from 'mastodon/api_types/polls'; +import emojify from 'mastodon/features/emoji/emoji'; + +import { CustomEmojiFactory, makeEmojiMap } from './custom_emoji'; +import type { CustomEmoji, EmojiMap } from './custom_emoji'; + +interface PollOptionTranslationShape { + title: string; + titleHtml: string; +} + +export type PollOptionTranslation = RecordOf; + +export const PollOptionTranslationFactory = Record({ + title: '', + titleHtml: '', +}); + +interface PollOptionShape extends Required { + voted: boolean; + titleHtml: string; + translation: PollOptionTranslation | null; +} + +export function createPollOptionTranslationFromServerJSON( + translation: { title: string }, + emojiMap: EmojiMap, +) { + return PollOptionTranslationFactory({ + ...translation, + titleHtml: emojify( + escapeTextContentForBrowser(translation.title), + emojiMap, + ), + }); +} + +export type PollOption = RecordOf; + +export const PollOptionFactory = Record({ + title: '', + votes_count: 0, + voted: false, + titleHtml: '', + translation: null, +}); + +interface PollShape + extends Omit { + emojis: List; + options: List; + own_votes?: List; +} +export type Poll = RecordOf; + +export const PollFactory = Record({ + id: '', + expires_at: '', + expired: false, + multiple: false, + voters_count: 0, + votes_count: 0, + voted: false, + emojis: List(), + options: List(), + own_votes: List(), +}); + +export function createPollFromServerJSON( + serverJSON: ApiPollJSON, + previousPoll?: Poll, +) { + const emojiMap = makeEmojiMap(serverJSON.emojis); + + return PollFactory({ + ...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, + ), + }); + + const prevOption = previousPoll?.options.get(index); + if (prevOption?.translation && prevOption.title === option.title) { + const { translation } = prevOption; + + option.set( + 'translation', + createPollOptionTranslationFromServerJSON(translation, emojiMap), + ); + } + + return option; + }), + ), + }); +} diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 6da6abd81b..52f7c7d485 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -27,7 +27,7 @@ import { notificationPolicyReducer } from './notification_policy'; import { notificationRequestsReducer } from './notification_requests'; import notifications from './notifications'; import { pictureInPictureReducer } from './picture_in_picture'; -import polls from './polls'; +import { pollsReducer } from './polls'; import push_notifications from './push_notifications'; import { relationshipsReducer } from './relationships'; import search from './search'; @@ -70,7 +70,7 @@ const reducers = { filters, conversations, suggestions: suggestionsReducer, - polls, + polls: pollsReducer, trends, markers: markersReducer, picture_in_picture: pictureInPictureReducer, diff --git a/app/javascript/mastodon/reducers/polls.js b/app/javascript/mastodon/reducers/polls.js deleted file mode 100644 index 5e8e775dac..0000000000 --- a/app/javascript/mastodon/reducers/polls.js +++ /dev/null @@ -1,45 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { POLLS_IMPORT } from 'mastodon/actions/importer'; - -import { normalizePollOptionTranslation } from '../actions/importer/normalizer'; -import { STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_UNDO } from '../actions/statuses'; - -const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); - -const statusTranslateSuccess = (state, pollTranslation) => { - return state.withMutations(map => { - if (pollTranslation) { - const poll = state.get(pollTranslation.id); - - pollTranslation.options.forEach((item, index) => { - map.setIn([pollTranslation.id, 'options', index, 'translation'], fromJS(normalizePollOptionTranslation(item, poll))); - }); - } - }); -}; - -const statusTranslateUndo = (state, id) => { - return state.withMutations(map => { - const options = map.getIn([id, 'options']); - - if (options) { - options.forEach((item, index) => map.deleteIn([id, 'options', index, 'translation'])); - } - }); -}; - -const initialState = ImmutableMap(); - -export default function polls(state = initialState, action) { - switch(action.type) { - case POLLS_IMPORT: - return importPolls(state, action.polls); - case STATUS_TRANSLATE_SUCCESS: - return statusTranslateSuccess(state, action.translation.poll); - case STATUS_TRANSLATE_UNDO: - return statusTranslateUndo(state, action.pollId); - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/polls.ts b/app/javascript/mastodon/reducers/polls.ts new file mode 100644 index 0000000000..9b9a5d2ff8 --- /dev/null +++ b/app/javascript/mastodon/reducers/polls.ts @@ -0,0 +1,67 @@ +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'; +import { createPollOptionTranslationFromServerJSON } from 'mastodon/models/poll'; +import type { Poll } from 'mastodon/models/poll'; + +import { + STATUS_TRANSLATE_SUCCESS, + STATUS_TRANSLATE_UNDO, +} from '../actions/statuses'; + +const initialState = ImmutableMap(); +type PollsState = typeof initialState; + +const statusTranslateSuccess = ( + state: PollsState, + pollTranslation: Poll | undefined, +) => { + if (!pollTranslation) return state; + + return state.withMutations((map) => { + const poll = state.get(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 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']), + ); + } + }); +}; + +export const pollsReducer: Reducer = ( + state = initialState, + action, +) => { + if (importPolls.match(action)) { + return state.withMutations((polls) => { + action.payload.polls.forEach((poll) => polls.set(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; +};