diff --git a/app/soapbox/normalizers/__tests__/poll-test.js b/app/soapbox/normalizers/__tests__/poll-test.js
new file mode 100644
index 000000000..4cc9dbbbc
--- /dev/null
+++ b/app/soapbox/normalizers/__tests__/poll-test.js
@@ -0,0 +1,48 @@
+import { Record as ImmutableRecord, fromJS } from 'immutable';
+
+import { normalizePoll } from '../poll';
+
+describe('normalizePoll()', () => {
+ it('adds base fields', () => {
+ const poll = fromJS({ options: [{ title: 'Apples' }] });
+ const result = normalizePoll(poll);
+
+ const expected = {
+ options: [{ title: 'Apples', votes_count: 0 }],
+ emojis: [],
+ expired: false,
+ multiple: false,
+ voters_count: 0,
+ votes_count: 0,
+ own_votes: null,
+ voted: false,
+ };
+
+ expect(ImmutableRecord.isRecord(result)).toBe(true);
+ expect(ImmutableRecord.isRecord(result.options.get(0))).toBe(true);
+ expect(result.toJS()).toMatchObject(expected);
+ expect(result.expires_at instanceof Date).toBe(true);
+ });
+
+ it('normalizes a Pleroma logged-out poll', () => {
+ const poll = fromJS(require('soapbox/__fixtures__/pleroma-status-with-poll.json')).get('poll');
+ const result = normalizePoll(poll);
+
+ // Adds logged-in fields
+ expect(result.voted).toBe(false);
+ expect(result.own_votes).toBe(null);
+ });
+
+ it('normalizes poll with emojis', () => {
+ const poll = fromJS(require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json')).get('poll');
+ const result = normalizePoll(poll);
+
+ // Emojifies poll options
+ expect(result.options.get(1).title_emojified)
+ .toEqual('Custom emoji
');
+
+ // Parses emojis as Immutable.Record's
+ expect(ImmutableRecord.isRecord(result.emojis.get(0))).toBe(true);
+ expect(result.emojis.get(1).shortcode).toEqual('soapbox');
+ });
+});
diff --git a/app/soapbox/normalizers/poll.ts b/app/soapbox/normalizers/poll.ts
new file mode 100644
index 000000000..fa127702e
--- /dev/null
+++ b/app/soapbox/normalizers/poll.ts
@@ -0,0 +1,88 @@
+/**
+ * Poll normalizer:
+ * Converts API polls into our internal format.
+ * @see {@link https://docs.joinmastodon.org/entities/poll/}
+ */
+import escapeTextContentForBrowser from 'escape-html';
+import {
+ Map as ImmutableMap,
+ List as ImmutableList,
+ Record as ImmutableRecord,
+} from 'immutable';
+
+import emojify from 'soapbox/features/emoji/emoji';
+import { normalizeEmoji } from 'soapbox/normalizers/emoji';
+import { makeEmojiMap } from 'soapbox/utils/normalizers';
+
+// https://docs.joinmastodon.org/entities/poll/
+const PollRecord = ImmutableRecord({
+ emojis: ImmutableList(),
+ expired: false,
+ expires_at: new Date(),
+ id: '',
+ multiple: false,
+ options: ImmutableList(),
+ voters_count: 0,
+ votes_count: 0,
+ own_votes: null,
+ voted: false,
+});
+
+// Sub-entity of Poll
+const PollOptionRecord = ImmutableRecord({
+ title: '',
+ votes_count: 0,
+
+ // Internal fields
+ title_emojified: '',
+});
+
+// Normalize emojis
+const normalizeEmojis = (entity: ImmutableMap) => {
+ return entity.update('emojis', ImmutableList(), emojis => {
+ return emojis.map(normalizeEmoji);
+ });
+};
+
+const normalizePollOption = (option: ImmutableMap, emojis: ImmutableList> = ImmutableList()) => {
+ const emojiMap = makeEmojiMap(emojis);
+ const titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
+
+ return PollOptionRecord(
+ option.set('title_emojified', titleEmojified),
+ );
+};
+
+// Normalize poll options
+const normalizePollOptions = (poll: ImmutableMap) => {
+ const emojis = poll.get('emojis');
+
+ return poll.update('options', (options: ImmutableList>) => {
+ return options.map(option => normalizePollOption(option, emojis));
+ });
+};
+
+// Normalize own_votes to `null` if empty (like Mastodon)
+const normalizePollOwnVotes = (poll: ImmutableMap) => {
+ return poll.update('own_votes', ownVotes => {
+ return ownVotes?.size > 0 ? ownVotes : null;
+ });
+};
+
+// Whether the user voted in the poll
+const normalizePollVoted = (poll: ImmutableMap) => {
+ return poll.update('voted', voted => {
+ return typeof voted === 'boolean' ? voted : poll.get('own_votes')?.size > 0;
+ });
+};
+
+export const normalizePoll = (poll: ImmutableMap) => {
+ return PollRecord(
+ poll.withMutations((poll: ImmutableMap) => {
+ normalizeEmojis(poll);
+ normalizePollOptions(poll);
+ normalizePollOwnVotes(poll);
+ normalizePollVoted(poll);
+ }),
+ );
+};
diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts
index a6d10aa73..91c52cfa3 100644
--- a/app/soapbox/normalizers/status.ts
+++ b/app/soapbox/normalizers/status.ts
@@ -3,18 +3,17 @@
* Converts API statuses into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/status/}
*/
-import escapeTextContentForBrowser from 'escape-html';
import {
Map as ImmutableMap,
List as ImmutableList,
Record as ImmutableRecord,
} from 'immutable';
-import emojify from 'soapbox/features/emoji/emoji';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizeMention } from 'soapbox/normalizers/mention';
+import { normalizePoll } from 'soapbox/normalizers/poll';
import { IStatus } from 'soapbox/types';
-import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers';
+import { mergeDefined } from 'soapbox/utils/normalizers';
// https://docs.joinmastodon.org/entities/status/
const StatusRecord = ImmutableRecord({
@@ -73,29 +72,6 @@ const AttachmentRecord = ImmutableRecord({
status: null,
});
-// https://docs.joinmastodon.org/entities/poll/
-const PollRecord = ImmutableRecord({
- emojis: ImmutableList(),
- expired: false,
- expires_at: new Date(),
- id: '',
- multiple: false,
- options: ImmutableList(),
- voters_count: 0,
- votes_count: 0,
- own_votes: null,
- voted: false,
-});
-
-// Sub-entity of Poll
-const PollOptionRecord = ImmutableRecord({
- title: '',
- votes_count: 0,
-
- // Internal fields
- title_emojified: '',
-});
-
// Ensure attachments have required fields
const normalizeAttachment = (attachment: ImmutableMap) => {
const url = [
@@ -131,50 +107,6 @@ const normalizeEmojis = (entity: ImmutableMap) => {
});
};
-const normalizePollOption = (option: ImmutableMap, emojis: ImmutableList> = ImmutableList()) => {
- const emojiMap = makeEmojiMap(emojis);
- const titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
-
- return PollOptionRecord(
- option.set('title_emojified', titleEmojified),
- );
-};
-
-// Normalize poll options
-const normalizePollOptions = (poll: ImmutableMap) => {
- const emojis = poll.get('emojis');
-
- return poll.update('options', (options: ImmutableList>) => {
- return options.map(option => normalizePollOption(option, emojis));
- });
-};
-
-// Normalize own_votes to `null` if empty (like Mastodon)
-const normalizePollOwnVotes = (poll: ImmutableMap) => {
- return poll.update('own_votes', ownVotes => {
- return ownVotes?.size > 0 ? ownVotes : null;
- });
-};
-
-// Whether the user voted in the poll
-const normalizePollVoted = (poll: ImmutableMap) => {
- return poll.update('voted', voted => {
- return typeof voted === 'boolean' ? voted : poll.get('own_votes')?.size > 0;
- });
-};
-
-// Normalize the actual poll
-const normalizePoll = (poll: ImmutableMap) => {
- return PollRecord(
- poll.withMutations((poll: ImmutableMap) => {
- normalizeEmojis(poll);
- normalizePollOptions(poll);
- normalizePollOwnVotes(poll);
- normalizePollVoted(poll);
- }),
- );
-};
-
// Normalize the poll in the status, if applicable
const normalizeStatusPoll = (status: ImmutableMap) => {
if (status.hasIn(['poll', 'options'])) {