From 1321d1279acf6fadba1dad2a7650a2a74cec4491 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Wed, 27 Mar 2024 23:08:54 +0100 Subject: [PATCH] Convert polls to Typescript / ImmutableRecords --- .../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 | 53 +++++++++ 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 +++++++++++ 13 files changed, 273 insertions(+), 184 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/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 16f191b584..875a538081 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,21 +71,15 @@ export function importFetchedStatuses(statuses) { } if (status.poll && 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))); } } 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 b5a30343e4..17170bbb9c 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'); @@ -101,38 +98,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 a37410dc90..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, getState) => { - dispatch(voteRequest()); - - api(getState).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, getState) => { - dispatch(fetchPollRequest()); - - api(getState).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..7c5c43a917 --- /dev/null +++ b/app/javascript/mastodon/actions/polls.ts @@ -0,0 +1,53 @@ +import type { ApiPollJSON } from 'mastodon/api_types/polls'; +import { createPollFromServerJSON } from 'mastodon/models/poll'; +import { createAppAsyncThunk } from 'mastodon/store'; + +import api from '../api'; + +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 = createAppAsyncThunk( + 'poll/vote', + async ( + args: { + pollId: string; + choices: unknown; + }, + { dispatch, getState }, + ) => { + const { pollId, choices } = args; + + const { data } = await api(getState).post( + `/api/v1/polls/${pollId}/votes`, + { + choices, + }, + ); + + await dispatch(importFetchedPoll({ poll: data })); + }, +); + +export const fetchPoll = createAppAsyncThunk( + 'poll/fetch', + async (args: { pollId: string }, { dispatch, getState }) => { + const { data } = await api(getState).get( + `/api/v1/polls/${args.pollId}`, + ); + + await dispatch(importFetchedPoll({ poll: data })); + }, +); diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx index c7036d111b..83ba8fe1e6 100644 --- a/app/javascript/mastodon/components/poll.jsx +++ b/app/javascript/mastodon/components/poll.jsx @@ -32,11 +32,6 @@ 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 contextTypes = { @@ -44,7 +39,7 @@ class Poll extends ImmutablePureComponent { }; static propTypes = { - poll: ImmutablePropTypes.map, + poll: ImmutablePropTypes.record.isRequired, lang: PropTypes.string, intl: PropTypes.object.isRequired, disabled: PropTypes.bool, @@ -147,7 +142,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 8482345431..0b246d4931 100644 --- a/app/javascript/mastodon/containers/poll_container.js +++ b/app/javascript/mastodon/containers/poll_container.js @@ -8,19 +8,19 @@ 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 })); }, }); 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 a04ebe6291..3c66d5f239 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 { @@ -99,15 +98,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..674ba683a4 --- /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' | 'options' | 'own_votes'> { + 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: List(serverJSON.own_votes), + options: List( + serverJSON.options.map((optionJSON, index) => { + const option = PollOptionFactory({ + ...optionJSON, + voted: serverJSON.own_votes.includes(index), + 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 6296ef2026..13baa15831 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -28,7 +28,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'; @@ -73,7 +73,7 @@ const reducers = { filters, conversations, suggestions, - 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; +};