{showResults ? (
diff --git a/app/soapbox/normalizers/__tests__/poll.test.ts b/app/soapbox/normalizers/__tests__/poll.test.ts
deleted file mode 100644
index d5226e938..000000000
--- a/app/soapbox/normalizers/__tests__/poll.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { Record as ImmutableRecord } from 'immutable';
-
-import { normalizePoll } from '../poll';
-
-describe('normalizePoll()', () => {
- it('adds base fields', () => {
- const poll = { 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);
- });
-
- it('normalizes a Pleroma logged-out poll', () => {
- const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll.json');
- 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 } = require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json');
- const result = normalizePoll(poll);
-
- // Emojifies poll options
- expect(result.options.get(1)?.title_emojified)
- .toContain('emojione');
-
- // 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/__tests__/status.test.ts b/app/soapbox/normalizers/__tests__/status.test.ts
index 5c66a4b9b..0024a212c 100644
--- a/app/soapbox/normalizers/__tests__/status.test.ts
+++ b/app/soapbox/normalizers/__tests__/status.test.ts
@@ -146,12 +146,16 @@ describe('normalizeStatus()', () => {
});
it('normalizes poll and poll options', () => {
- const status = { poll: { options: [{ title: 'Apples' }] } };
+ const status = { poll: { id: '1', options: [{ title: 'Apples' }, { title: 'Oranges' }] } };
const result = normalizeStatus(status);
const poll = result.poll as Poll;
const expected = {
- options: [{ title: 'Apples', votes_count: 0 }],
+ id: '1',
+ options: [
+ { title: 'Apples', votes_count: 0 },
+ { title: 'Oranges', votes_count: 0 },
+ ],
emojis: [],
expired: false,
multiple: false,
@@ -161,9 +165,7 @@ describe('normalizeStatus()', () => {
voted: false,
};
- expect(ImmutableRecord.isRecord(poll)).toBe(true);
- expect(ImmutableRecord.isRecord(poll.options.get(0))).toBe(true);
- expect(poll.toJS()).toMatchObject(expected);
+ expect(poll).toMatchObject(expected);
});
it('normalizes a Pleroma logged-out poll', () => {
@@ -182,12 +184,10 @@ describe('normalizeStatus()', () => {
const poll = result.poll as Poll;
// Emojifies poll options
- expect(poll.options.get(1)?.title_emojified)
+ expect(poll.options[1].title_emojified)
.toContain('emojione');
- // Parses emojis as Immutable.Record's
- expect(ImmutableRecord.isRecord(poll.emojis.get(0))).toBe(true);
- expect(poll.emojis.get(1)?.shortcode).toEqual('soapbox');
+ expect(poll.emojis[1].shortcode).toEqual('soapbox');
});
it('normalizes a card', () => {
diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts
index e7100fa9d..12bb77d0c 100644
--- a/app/soapbox/normalizers/index.ts
+++ b/app/soapbox/normalizers/index.ts
@@ -18,7 +18,6 @@ export { ListRecord, normalizeList } from './list';
export { LocationRecord, normalizeLocation } from './location';
export { MentionRecord, normalizeMention } from './mention';
export { NotificationRecord, normalizeNotification } from './notification';
-export { PollRecord, PollOptionRecord, normalizePoll } from './poll';
export { StatusRecord, normalizeStatus } from './status';
export { StatusEditRecord, normalizeStatusEdit } from './status-edit';
export { TagRecord, normalizeTag } from './tag';
diff --git a/app/soapbox/normalizers/poll.ts b/app/soapbox/normalizers/poll.ts
deleted file mode 100644
index 726278a57..000000000
--- a/app/soapbox/normalizers/poll.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * 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,
- fromJS,
-} from 'immutable';
-
-import emojify from 'soapbox/features/emoji';
-import { normalizeEmoji } from 'soapbox/normalizers/emoji';
-import { makeEmojiMap } from 'soapbox/utils/normalizers';
-
-import type { Emoji, PollOption } from 'soapbox/types/entities';
-
-// https://docs.joinmastodon.org/entities/poll/
-export const PollRecord = ImmutableRecord({
- emojis: ImmutableList
(),
- expired: false,
- expires_at: '',
- id: '',
- multiple: false,
- options: ImmutableList(),
- voters_count: 0,
- votes_count: 0,
- own_votes: null as ImmutableList | null,
- voted: false,
- pleroma: ImmutableMap(),
-});
-
-// Sub-entity of Poll
-export 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 | string, emojis: ImmutableList> = ImmutableList()) => {
- const emojiMap = makeEmojiMap(emojis);
-
- if (typeof option === 'string') {
- const titleEmojified = emojify(escapeTextContentForBrowser(option), emojiMap);
-
- return PollOptionRecord({
- title: option,
- title_emojified: titleEmojified,
- });
- }
-
- 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: Record) => {
- return PollRecord(
- ImmutableMap(fromJS(poll)).withMutations((poll: ImmutableMap) => {
- normalizeEmojis(poll);
- normalizePollOptions(poll);
- normalizePollOwnVotes(poll);
- normalizePollVoted(poll);
- }),
- );
-};
diff --git a/app/soapbox/normalizers/status-edit.ts b/app/soapbox/normalizers/status-edit.ts
index 6f5d8d53a..f569ecce3 100644
--- a/app/soapbox/normalizers/status-edit.ts
+++ b/app/soapbox/normalizers/status-edit.ts
@@ -12,7 +12,7 @@ import {
import emojify from 'soapbox/features/emoji';
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
-import { normalizePoll } from 'soapbox/normalizers/poll';
+import { pollSchema } from 'soapbox/schemas';
import { stripCompatibilityFeatures } from 'soapbox/utils/html';
import { makeEmojiMap } from 'soapbox/utils/normalizers';
@@ -50,9 +50,10 @@ const normalizeEmojis = (entity: ImmutableMap) => {
// Normalize the poll in the status, if applicable
const normalizeStatusPoll = (statusEdit: ImmutableMap) => {
- if (statusEdit.hasIn(['poll', 'options'])) {
- return statusEdit.update('poll', ImmutableMap(), normalizePoll);
- } else {
+ try {
+ const poll = pollSchema.parse(statusEdit.get('poll').toJS());
+ return statusEdit.set('poll', poll);
+ } catch (_e) {
return statusEdit.set('poll', null);
}
};
diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts
index 64a00b316..17a23b19e 100644
--- a/app/soapbox/normalizers/status.ts
+++ b/app/soapbox/normalizers/status.ts
@@ -13,8 +13,7 @@ import {
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizeMention } from 'soapbox/normalizers/mention';
-import { normalizePoll } from 'soapbox/normalizers/poll';
-import { cardSchema } from 'soapbox/schemas/card';
+import { cardSchema, pollSchema } from 'soapbox/schemas';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
@@ -109,9 +108,10 @@ const normalizeEmojis = (entity: ImmutableMap) => {
// Normalize the poll in the status, if applicable
const normalizeStatusPoll = (status: ImmutableMap) => {
- if (status.hasIn(['poll', 'options'])) {
- return status.update('poll', ImmutableMap(), normalizePoll);
- } else {
+ try {
+ const poll = pollSchema.parse(status.get('poll').toJS());
+ return status.set('poll', poll);
+ } catch (_e) {
return status.set('poll', null);
}
};
diff --git a/app/soapbox/reducers/__tests__/polls.test.ts b/app/soapbox/reducers/__tests__/polls.test.ts
index b9ceb07f7..74627fb68 100644
--- a/app/soapbox/reducers/__tests__/polls.test.ts
+++ b/app/soapbox/reducers/__tests__/polls.test.ts
@@ -11,14 +11,17 @@ describe('polls reducer', () => {
describe('POLLS_IMPORT', () => {
it('normalizes the poll', () => {
- const polls = [{ id: '3', options: [{ title: 'Apples' }] }];
+ const polls = [{ id: '3', options: [{ title: 'Apples' }, { title: 'Oranges' }] }];
const action = { type: POLLS_IMPORT, polls };
const result = reducer(undefined, action);
const expected = {
'3': {
- options: [{ title: 'Apples', votes_count: 0 }],
+ options: [
+ { title: 'Apples', votes_count: 0 },
+ { title: 'Oranges', votes_count: 0 },
+ ],
emojis: [],
expired: false,
multiple: false,
diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts
index 3ae9c308f..d8c91b6a3 100644
--- a/app/soapbox/reducers/statuses.ts
+++ b/app/soapbox/reducers/statuses.ts
@@ -74,11 +74,11 @@ const minifyStatus = (status: StatusRecord): ReducerStatus => {
};
// Gets titles of poll options from status
-const getPollOptionTitles = ({ poll }: StatusRecord): ImmutableList => {
+const getPollOptionTitles = ({ poll }: StatusRecord): readonly string[] => {
if (poll && typeof poll === 'object') {
return poll.options.map(({ title }) => title);
} else {
- return ImmutableList();
+ return [];
}
};
diff --git a/app/soapbox/schemas/__tests__/poll.test.ts b/app/soapbox/schemas/__tests__/poll.test.ts
new file mode 100644
index 000000000..fe39315dd
--- /dev/null
+++ b/app/soapbox/schemas/__tests__/poll.test.ts
@@ -0,0 +1,44 @@
+import { pollSchema } from '../poll';
+
+describe('normalizePoll()', () => {
+ it('adds base fields', () => {
+ const poll = { id: '1', options: [{ title: 'Apples' }, { title: 'Oranges' }] };
+ const result = pollSchema.parse(poll);
+
+ const expected = {
+ options: [
+ { title: 'Apples', votes_count: 0 },
+ { title: 'Oranges', votes_count: 0 },
+ ],
+ emojis: [],
+ expired: false,
+ multiple: false,
+ voters_count: 0,
+ votes_count: 0,
+ own_votes: null,
+ voted: false,
+ };
+
+ expect(result).toMatchObject(expected);
+ });
+
+ it('normalizes a Pleroma logged-out poll', () => {
+ const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll.json');
+ const result = pollSchema.parse(poll);
+
+ // Adds logged-in fields
+ expect(result.voted).toBe(false);
+ expect(result.own_votes).toBe(null);
+ });
+
+ it('normalizes poll with emojis', () => {
+ const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json');
+ const result = pollSchema.parse(poll);
+
+ // Emojifies poll options
+ expect(result.options[1]?.title_emojified)
+ .toContain('emojione');
+
+ expect(result.emojis[1]?.shortcode).toEqual('soapbox');
+ });
+});
diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts
index a6fb6b4c3..9381edbbd 100644
--- a/app/soapbox/schemas/account.ts
+++ b/app/soapbox/schemas/account.ts
@@ -43,8 +43,8 @@ const accountSchema = z.object({
pleroma: z.any(), // TODO
source: z.any(), // TODO
statuses_count: z.number().catch(0),
- uri: z.string().catch(''),
- url: z.string().catch(''),
+ uri: z.string().url().catch(''),
+ url: z.string().url().catch(''),
username: z.string().catch(''),
verified: z.boolean().default(false),
website: z.string().catch(''),
diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts
index 29b1c2edd..655bffc7f 100644
--- a/app/soapbox/schemas/index.ts
+++ b/app/soapbox/schemas/index.ts
@@ -6,6 +6,7 @@ export { groupSchema, type Group } from './group';
export { groupMemberSchema, type GroupMember } from './group-member';
export { groupRelationshipSchema, type GroupRelationship } from './group-relationship';
export { groupTagSchema, type GroupTag } from './group-tag';
+export { pollSchema, type Poll, type PollOption } from './poll';
export { relationshipSchema, type Relationship } from './relationship';
// Soapbox
diff --git a/app/soapbox/schemas/poll.ts b/app/soapbox/schemas/poll.ts
new file mode 100644
index 000000000..73d27753a
--- /dev/null
+++ b/app/soapbox/schemas/poll.ts
@@ -0,0 +1,50 @@
+import escapeTextContentForBrowser from 'escape-html';
+import { z } from 'zod';
+
+import emojify from 'soapbox/features/emoji';
+
+import { customEmojiSchema } from './custom-emoji';
+import { filteredArray, makeCustomEmojiMap } from './utils';
+
+const pollOptionSchema = z.object({
+ title: z.string().catch(''),
+ votes_count: z.number().catch(0),
+});
+
+const pollSchema = z.object({
+ emojis: filteredArray(customEmojiSchema),
+ expired: z.boolean().catch(false),
+ expires_at: z.string().datetime().catch(new Date().toUTCString()),
+ id: z.string(),
+ multiple: z.boolean().catch(false),
+ options: z.array(pollOptionSchema).min(2),
+ voters_count: z.number().catch(0),
+ votes_count: z.number().catch(0),
+ own_votes: z.array(z.number()).nonempty().nullable().catch(null),
+ voted: z.boolean().catch(false),
+ pleroma: z.object({
+ non_anonymous: z.boolean().catch(false),
+ }).optional().catch(undefined),
+}).transform((poll) => {
+ const emojiMap = makeCustomEmojiMap(poll.emojis);
+
+ const emojifiedOptions = poll.options.map((option) => ({
+ ...option,
+ title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
+ }));
+
+ // If the user has votes, they have certainly voted.
+ if (poll.own_votes?.length) {
+ poll.voted = true;
+ }
+
+ return {
+ ...poll,
+ options: emojifiedOptions,
+ };
+});
+
+type Poll = z.infer;
+type PollOption = Poll['options'][number];
+
+export { pollSchema, type Poll, type PollOption };
\ No newline at end of file
diff --git a/app/soapbox/schemas/soapbox/ad.ts b/app/soapbox/schemas/soapbox/ad.ts
index 343b519b2..40dc05fb3 100644
--- a/app/soapbox/schemas/soapbox/ad.ts
+++ b/app/soapbox/schemas/soapbox/ad.ts
@@ -5,7 +5,7 @@ import { cardSchema } from '../card';
const adSchema = z.object({
card: cardSchema,
impression: z.string().optional().catch(undefined),
- expires_at: z.string().optional().catch(undefined),
+ expires_at: z.string().datetime().optional().catch(undefined),
reason: z.string().optional().catch(undefined),
});
diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts
index e99aa3acf..712a89e23 100644
--- a/app/soapbox/types/entities.ts
+++ b/app/soapbox/types/entities.ts
@@ -18,8 +18,6 @@ import {
LocationRecord,
MentionRecord,
NotificationRecord,
- PollRecord,
- PollOptionRecord,
StatusEditRecord,
StatusRecord,
TagRecord,
@@ -47,8 +45,6 @@ type List = ReturnType;
type Location = ReturnType;
type Mention = ReturnType;
type Notification = ReturnType;
-type Poll = ReturnType;
-type PollOption = ReturnType;
type StatusEdit = ReturnType;
type Tag = ReturnType;
@@ -89,8 +85,6 @@ export {
Location,
Mention,
Notification,
- Poll,
- PollOption,
Status,
StatusEdit,
Tag,
@@ -106,5 +100,7 @@ export type {
Group,
GroupMember,
GroupRelationship,
+ Poll,
+ PollOption,
Relationship,
} from 'soapbox/schemas';
\ No newline at end of file