diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index d906bdfb14c..7f7876e560d 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?.author_account) { @@ -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 be76b0f3916..17ba712e366 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'); @@ -105,38 +102,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 00000000000..5bbe7d57d67 --- /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 aa49341444c..00000000000 --- 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 00000000000..1c1a2a7e39e --- /dev/null +++ b/app/javascript/mastodon/actions/polls.ts @@ -0,0 +1,44 @@ +import type { ApiPollJSON } from 'mastodon/api_types/polls'; +import { createPollFromServerJSON } from 'mastodon/models/poll'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from 'mastodon/store/typed_functions'; + +import { apiRequest } 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 = createDataLoadingThunk( + 'poll/vote', + ({ pollId, choices }: { pollId: string; choices: unknown }) => + apiRequest('POST', `/v1/polls/${pollId}/votes`, { + choices, + }), + async (poll, { dispatch, discardLoadData }) => { + await dispatch(importFetchedPoll({ poll })); + return discardLoadData; + }, +); + +export const fetchPoll = createDataLoadingThunk( + 'poll/fetch', + ({ pollId }: { pollId: string }) => + apiRequest('GET', `/v1/polls/${pollId}`), + async (poll, { dispatch }) => { + await dispatch(importFetchedPoll({ poll })); + }, +); diff --git a/app/javascript/mastodon/api_types/polls.ts b/app/javascript/mastodon/api_types/polls.ts index 8181f7b813b..07de107de42 100644 --- a/app/javascript/mastodon/api_types/polls.ts +++ b/app/javascript/mastodon/api_types/polls.ts @@ -9,15 +9,15 @@ export interface ApiPollOptionJSON { export interface ApiPollJSON { id: string; - expires_at: string; + expires_at?: string; expired: boolean; multiple: boolean; votes_count: number; - voters_count: number; + voters_count?: number; 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 7b836f00b1a..2fdab730d3f 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, + poll: ImmutablePropTypes.record.isRequired, lang: PropTypes.string, intl: PropTypes.object.isRequired, disabled: PropTypes.bool, @@ -144,7 +139,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 84823454316..0b246d49312 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 a04ebe62915..3c66d5f2397 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 76479f3aebf..5297dcd4704 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 00000000000..ca61a88ae0f --- /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: undefined, + 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) || 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 6296ef20269..13baa158319 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 5e8e775dac8..00000000000 --- 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 00000000000..9b9a5d2ff8e --- /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; +};