diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js
index 516a7a79733..047cf11910c 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 c09a3f442c7..c2918ef8d56 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 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 b92de0dbcda..7a951206325 100644
--- a/app/javascript/mastodon/reducers/index.ts
+++ b/app/javascript/mastodon/reducers/index.ts
@@ -29,7 +29,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';
@@ -75,7 +75,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;
+};