diff --git a/app/soapbox/components/polls/__tests__/poll-footer.test.tsx b/app/soapbox/components/polls/__tests__/poll-footer.test.tsx index 29c841a0a..a9e709399 100644 --- a/app/soapbox/components/polls/__tests__/poll-footer.test.tsx +++ b/app/soapbox/components/polls/__tests__/poll-footer.test.tsx @@ -4,14 +4,22 @@ import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; import { __stub } from 'soapbox/api'; -import { normalizePoll } from 'soapbox/normalizers/poll'; +import { mockStore, render, screen, rootState } from 'soapbox/jest/test-helpers'; +import { type Poll } from 'soapbox/schemas'; -import { mockStore, render, screen, rootState } from '../../../jest/test-helpers'; import PollFooter from '../poll-footer'; -let poll = normalizePoll({ - id: 1, - options: [{ title: 'Apples', votes_count: 0 }], +let poll: Poll = { + id: '1', + options: [{ + title: 'Apples', + votes_count: 0, + title_emojified: 'Apples', + }, { + title: 'Oranges', + votes_count: 0, + title_emojified: 'Oranges', + }], emojis: [], expired: false, expires_at: '2020-03-24T19:33:06.000Z', @@ -20,7 +28,7 @@ let poll = normalizePoll({ votes_count: 0, own_votes: null, voted: false, -}); +}; describe('', () => { describe('with "showResults" enabled', () => { @@ -62,10 +70,10 @@ describe('', () => { describe('when the Poll has not expired', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, expired: false, - }); + }; }); it('renders time remaining', () => { @@ -77,10 +85,10 @@ describe('', () => { describe('when the Poll has expired', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, expired: true, - }); + }; }); it('renders closed', () => { @@ -100,10 +108,10 @@ describe('', () => { describe('when the Poll is multiple', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, multiple: true, - }); + }; }); it('renders the Vote button', () => { @@ -115,10 +123,10 @@ describe('', () => { describe('when the Poll is not multiple', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, multiple: false, - }); + }; }); it('does not render the Vote button', () => { diff --git a/app/soapbox/components/polls/poll-footer.tsx b/app/soapbox/components/polls/poll-footer.tsx index c62cc9522..1994c1e76 100644 --- a/app/soapbox/components/polls/poll-footer.tsx +++ b/app/soapbox/components/polls/poll-footer.tsx @@ -40,21 +40,21 @@ const PollFooter: React.FC = ({ poll, showResults, selected }): JSX let votesCount = null; if (poll.voters_count !== null && poll.voters_count !== undefined) { - votesCount = ; + votesCount = ; } else { - votesCount = ; + votesCount = ; } return ( - {(!showResults && poll?.multiple) && ( + {(!showResults && poll.multiple) && ( )} - {poll.pleroma.get('non_anonymous') && ( + {poll.pleroma?.non_anonymous && ( <> diff --git a/app/soapbox/components/polls/poll-option.tsx b/app/soapbox/components/polls/poll-option.tsx index 792a3a066..b4c37e11d 100644 --- a/app/soapbox/components/polls/poll-option.tsx +++ b/app/soapbox/components/polls/poll-option.tsx @@ -112,10 +112,13 @@ const PollOption: React.FC = (props): JSX.Element | null => { const pollVotesCount = poll.voters_count || poll.votes_count; const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100; - const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count); const voted = poll.own_votes?.includes(index); const message = intl.formatMessage(messages.votes, { votes: option.votes_count }); + const leading = poll.options + .filter(other => other.title !== option.title) + .every(other => option.votes_count >= other.votes_count); + return (
{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