Normalize poll with zod
This commit is contained in:
parent
211fdd52f5
commit
d4ed442a7e
|
@ -4,14 +4,22 @@ import { IntlProvider } from 'react-intl';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
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';
|
import PollFooter from '../poll-footer';
|
||||||
|
|
||||||
let poll = normalizePoll({
|
let poll: Poll = {
|
||||||
id: 1,
|
id: '1',
|
||||||
options: [{ title: 'Apples', votes_count: 0 }],
|
options: [{
|
||||||
|
title: 'Apples',
|
||||||
|
votes_count: 0,
|
||||||
|
title_emojified: 'Apples',
|
||||||
|
}, {
|
||||||
|
title: 'Oranges',
|
||||||
|
votes_count: 0,
|
||||||
|
title_emojified: 'Oranges',
|
||||||
|
}],
|
||||||
emojis: [],
|
emojis: [],
|
||||||
expired: false,
|
expired: false,
|
||||||
expires_at: '2020-03-24T19:33:06.000Z',
|
expires_at: '2020-03-24T19:33:06.000Z',
|
||||||
|
@ -20,7 +28,7 @@ let poll = normalizePoll({
|
||||||
votes_count: 0,
|
votes_count: 0,
|
||||||
own_votes: null,
|
own_votes: null,
|
||||||
voted: false,
|
voted: false,
|
||||||
});
|
};
|
||||||
|
|
||||||
describe('<PollFooter />', () => {
|
describe('<PollFooter />', () => {
|
||||||
describe('with "showResults" enabled', () => {
|
describe('with "showResults" enabled', () => {
|
||||||
|
@ -62,10 +70,10 @@ describe('<PollFooter />', () => {
|
||||||
|
|
||||||
describe('when the Poll has not expired', () => {
|
describe('when the Poll has not expired', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
poll = normalizePoll({
|
poll = {
|
||||||
...poll.toJS(),
|
...poll,
|
||||||
expired: false,
|
expired: false,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders time remaining', () => {
|
it('renders time remaining', () => {
|
||||||
|
@ -77,10 +85,10 @@ describe('<PollFooter />', () => {
|
||||||
|
|
||||||
describe('when the Poll has expired', () => {
|
describe('when the Poll has expired', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
poll = normalizePoll({
|
poll = {
|
||||||
...poll.toJS(),
|
...poll,
|
||||||
expired: true,
|
expired: true,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders closed', () => {
|
it('renders closed', () => {
|
||||||
|
@ -100,10 +108,10 @@ describe('<PollFooter />', () => {
|
||||||
|
|
||||||
describe('when the Poll is multiple', () => {
|
describe('when the Poll is multiple', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
poll = normalizePoll({
|
poll = {
|
||||||
...poll.toJS(),
|
...poll,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the Vote button', () => {
|
it('renders the Vote button', () => {
|
||||||
|
@ -115,10 +123,10 @@ describe('<PollFooter />', () => {
|
||||||
|
|
||||||
describe('when the Poll is not multiple', () => {
|
describe('when the Poll is not multiple', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
poll = normalizePoll({
|
poll = {
|
||||||
...poll.toJS(),
|
...poll,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render the Vote button', () => {
|
it('does not render the Vote button', () => {
|
||||||
|
|
|
@ -40,21 +40,21 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
|
||||||
let votesCount = null;
|
let votesCount = null;
|
||||||
|
|
||||||
if (poll.voters_count !== null && poll.voters_count !== undefined) {
|
if (poll.voters_count !== null && poll.voters_count !== undefined) {
|
||||||
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
|
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.voters_count }} />;
|
||||||
} else {
|
} else {
|
||||||
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
|
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.votes_count }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack space={4} data-testid='poll-footer'>
|
<Stack space={4} data-testid='poll-footer'>
|
||||||
{(!showResults && poll?.multiple) && (
|
{(!showResults && poll.multiple) && (
|
||||||
<Button onClick={handleVote} theme='primary' block>
|
<Button onClick={handleVote} theme='primary' block>
|
||||||
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
|
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HStack space={1.5} alignItems='center' wrap>
|
<HStack space={1.5} alignItems='center' wrap>
|
||||||
{poll.pleroma.get('non_anonymous') && (
|
{poll.pleroma?.non_anonymous && (
|
||||||
<>
|
<>
|
||||||
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
|
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
|
||||||
<Text theme='muted' weight='medium'>
|
<Text theme='muted' weight='medium'>
|
||||||
|
|
|
@ -112,10 +112,13 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
|
||||||
|
|
||||||
const pollVotesCount = poll.voters_count || poll.votes_count;
|
const pollVotesCount = poll.voters_count || poll.votes_count;
|
||||||
const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100;
|
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 voted = poll.own_votes?.includes(index);
|
||||||
const message = intl.formatMessage(messages.votes, { votes: option.votes_count });
|
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 (
|
return (
|
||||||
<div key={option.title}>
|
<div key={option.title}>
|
||||||
{showResults ? (
|
{showResults ? (
|
||||||
|
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -146,12 +146,16 @@ describe('normalizeStatus()', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes poll and poll options', () => {
|
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 result = normalizeStatus(status);
|
||||||
const poll = result.poll as Poll;
|
const poll = result.poll as Poll;
|
||||||
|
|
||||||
const expected = {
|
const expected = {
|
||||||
options: [{ title: 'Apples', votes_count: 0 }],
|
id: '1',
|
||||||
|
options: [
|
||||||
|
{ title: 'Apples', votes_count: 0 },
|
||||||
|
{ title: 'Oranges', votes_count: 0 },
|
||||||
|
],
|
||||||
emojis: [],
|
emojis: [],
|
||||||
expired: false,
|
expired: false,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
@ -161,9 +165,7 @@ describe('normalizeStatus()', () => {
|
||||||
voted: false,
|
voted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(ImmutableRecord.isRecord(poll)).toBe(true);
|
expect(poll).toMatchObject(expected);
|
||||||
expect(ImmutableRecord.isRecord(poll.options.get(0))).toBe(true);
|
|
||||||
expect(poll.toJS()).toMatchObject(expected);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes a Pleroma logged-out poll', () => {
|
it('normalizes a Pleroma logged-out poll', () => {
|
||||||
|
@ -182,12 +184,10 @@ describe('normalizeStatus()', () => {
|
||||||
const poll = result.poll as Poll;
|
const poll = result.poll as Poll;
|
||||||
|
|
||||||
// Emojifies poll options
|
// Emojifies poll options
|
||||||
expect(poll.options.get(1)?.title_emojified)
|
expect(poll.options[1].title_emojified)
|
||||||
.toContain('emojione');
|
.toContain('emojione');
|
||||||
|
|
||||||
// Parses emojis as Immutable.Record's
|
expect(poll.emojis[1].shortcode).toEqual('soapbox');
|
||||||
expect(ImmutableRecord.isRecord(poll.emojis.get(0))).toBe(true);
|
|
||||||
expect(poll.emojis.get(1)?.shortcode).toEqual('soapbox');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes a card', () => {
|
it('normalizes a card', () => {
|
||||||
|
|
|
@ -18,7 +18,6 @@ export { ListRecord, normalizeList } from './list';
|
||||||
export { LocationRecord, normalizeLocation } from './location';
|
export { LocationRecord, normalizeLocation } from './location';
|
||||||
export { MentionRecord, normalizeMention } from './mention';
|
export { MentionRecord, normalizeMention } from './mention';
|
||||||
export { NotificationRecord, normalizeNotification } from './notification';
|
export { NotificationRecord, normalizeNotification } from './notification';
|
||||||
export { PollRecord, PollOptionRecord, normalizePoll } from './poll';
|
|
||||||
export { StatusRecord, normalizeStatus } from './status';
|
export { StatusRecord, normalizeStatus } from './status';
|
||||||
export { StatusEditRecord, normalizeStatusEdit } from './status-edit';
|
export { StatusEditRecord, normalizeStatusEdit } from './status-edit';
|
||||||
export { TagRecord, normalizeTag } from './tag';
|
export { TagRecord, normalizeTag } from './tag';
|
||||||
|
|
|
@ -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<Emoji>(),
|
|
||||||
expired: false,
|
|
||||||
expires_at: '',
|
|
||||||
id: '',
|
|
||||||
multiple: false,
|
|
||||||
options: ImmutableList<PollOption>(),
|
|
||||||
voters_count: 0,
|
|
||||||
votes_count: 0,
|
|
||||||
own_votes: null as ImmutableList<number> | null,
|
|
||||||
voted: false,
|
|
||||||
pleroma: ImmutableMap<string, any>(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sub-entity of Poll
|
|
||||||
export const PollOptionRecord = ImmutableRecord({
|
|
||||||
title: '',
|
|
||||||
votes_count: 0,
|
|
||||||
|
|
||||||
// Internal fields
|
|
||||||
title_emojified: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Normalize emojis
|
|
||||||
const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
|
|
||||||
return entity.update('emojis', ImmutableList(), emojis => {
|
|
||||||
return emojis.map(normalizeEmoji);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePollOption = (option: ImmutableMap<string, any> | string, emojis: ImmutableList<ImmutableMap<string, string>> = 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<string, any>) => {
|
|
||||||
const emojis = poll.get('emojis');
|
|
||||||
|
|
||||||
return poll.update('options', (options: ImmutableList<ImmutableMap<string, any>>) => {
|
|
||||||
return options.map(option => normalizePollOption(option, emojis));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Normalize own_votes to `null` if empty (like Mastodon)
|
|
||||||
const normalizePollOwnVotes = (poll: ImmutableMap<string, any>) => {
|
|
||||||
return poll.update('own_votes', ownVotes => {
|
|
||||||
return ownVotes?.size > 0 ? ownVotes : null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Whether the user voted in the poll
|
|
||||||
const normalizePollVoted = (poll: ImmutableMap<string, any>) => {
|
|
||||||
return poll.update('voted', voted => {
|
|
||||||
return typeof voted === 'boolean' ? voted : poll.get('own_votes')?.size > 0;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizePoll = (poll: Record<string, any>) => {
|
|
||||||
return PollRecord(
|
|
||||||
ImmutableMap(fromJS(poll)).withMutations((poll: ImmutableMap<string, any>) => {
|
|
||||||
normalizeEmojis(poll);
|
|
||||||
normalizePollOptions(poll);
|
|
||||||
normalizePollOwnVotes(poll);
|
|
||||||
normalizePollVoted(poll);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
import emojify from 'soapbox/features/emoji';
|
import emojify from 'soapbox/features/emoji';
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
||||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
import { pollSchema } from 'soapbox/schemas';
|
||||||
import { stripCompatibilityFeatures } from 'soapbox/utils/html';
|
import { stripCompatibilityFeatures } from 'soapbox/utils/html';
|
||||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||||
|
|
||||||
|
@ -50,9 +50,10 @@ const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
|
||||||
|
|
||||||
// Normalize the poll in the status, if applicable
|
// Normalize the poll in the status, if applicable
|
||||||
const normalizeStatusPoll = (statusEdit: ImmutableMap<string, any>) => {
|
const normalizeStatusPoll = (statusEdit: ImmutableMap<string, any>) => {
|
||||||
if (statusEdit.hasIn(['poll', 'options'])) {
|
try {
|
||||||
return statusEdit.update('poll', ImmutableMap(), normalizePoll);
|
const poll = pollSchema.parse(statusEdit.get('poll').toJS());
|
||||||
} else {
|
return statusEdit.set('poll', poll);
|
||||||
|
} catch (_e) {
|
||||||
return statusEdit.set('poll', null);
|
return statusEdit.set('poll', null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,8 +13,7 @@ import {
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
||||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||||
import { normalizeMention } from 'soapbox/normalizers/mention';
|
import { normalizeMention } from 'soapbox/normalizers/mention';
|
||||||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
import { cardSchema, pollSchema } from 'soapbox/schemas';
|
||||||
import { cardSchema } from 'soapbox/schemas/card';
|
|
||||||
|
|
||||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||||
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||||
|
@ -109,9 +108,10 @@ const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
|
||||||
|
|
||||||
// Normalize the poll in the status, if applicable
|
// Normalize the poll in the status, if applicable
|
||||||
const normalizeStatusPoll = (status: ImmutableMap<string, any>) => {
|
const normalizeStatusPoll = (status: ImmutableMap<string, any>) => {
|
||||||
if (status.hasIn(['poll', 'options'])) {
|
try {
|
||||||
return status.update('poll', ImmutableMap(), normalizePoll);
|
const poll = pollSchema.parse(status.get('poll').toJS());
|
||||||
} else {
|
return status.set('poll', poll);
|
||||||
|
} catch (_e) {
|
||||||
return status.set('poll', null);
|
return status.set('poll', null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,14 +11,17 @@ describe('polls reducer', () => {
|
||||||
|
|
||||||
describe('POLLS_IMPORT', () => {
|
describe('POLLS_IMPORT', () => {
|
||||||
it('normalizes the poll', () => {
|
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 action = { type: POLLS_IMPORT, polls };
|
||||||
|
|
||||||
const result = reducer(undefined, action);
|
const result = reducer(undefined, action);
|
||||||
|
|
||||||
const expected = {
|
const expected = {
|
||||||
'3': {
|
'3': {
|
||||||
options: [{ title: 'Apples', votes_count: 0 }],
|
options: [
|
||||||
|
{ title: 'Apples', votes_count: 0 },
|
||||||
|
{ title: 'Oranges', votes_count: 0 },
|
||||||
|
],
|
||||||
emojis: [],
|
emojis: [],
|
||||||
expired: false,
|
expired: false,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
|
|
@ -74,11 +74,11 @@ const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gets titles of poll options from status
|
// Gets titles of poll options from status
|
||||||
const getPollOptionTitles = ({ poll }: StatusRecord): ImmutableList<string> => {
|
const getPollOptionTitles = ({ poll }: StatusRecord): readonly string[] => {
|
||||||
if (poll && typeof poll === 'object') {
|
if (poll && typeof poll === 'object') {
|
||||||
return poll.options.map(({ title }) => title);
|
return poll.options.map(({ title }) => title);
|
||||||
} else {
|
} else {
|
||||||
return ImmutableList();
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -43,8 +43,8 @@ const accountSchema = z.object({
|
||||||
pleroma: z.any(), // TODO
|
pleroma: z.any(), // TODO
|
||||||
source: z.any(), // TODO
|
source: z.any(), // TODO
|
||||||
statuses_count: z.number().catch(0),
|
statuses_count: z.number().catch(0),
|
||||||
uri: z.string().catch(''),
|
uri: z.string().url().catch(''),
|
||||||
url: z.string().catch(''),
|
url: z.string().url().catch(''),
|
||||||
username: z.string().catch(''),
|
username: z.string().catch(''),
|
||||||
verified: z.boolean().default(false),
|
verified: z.boolean().default(false),
|
||||||
website: z.string().catch(''),
|
website: z.string().catch(''),
|
||||||
|
|
|
@ -6,6 +6,7 @@ export { groupSchema, type Group } from './group';
|
||||||
export { groupMemberSchema, type GroupMember } from './group-member';
|
export { groupMemberSchema, type GroupMember } from './group-member';
|
||||||
export { groupRelationshipSchema, type GroupRelationship } from './group-relationship';
|
export { groupRelationshipSchema, type GroupRelationship } from './group-relationship';
|
||||||
export { groupTagSchema, type GroupTag } from './group-tag';
|
export { groupTagSchema, type GroupTag } from './group-tag';
|
||||||
|
export { pollSchema, type Poll, type PollOption } from './poll';
|
||||||
export { relationshipSchema, type Relationship } from './relationship';
|
export { relationshipSchema, type Relationship } from './relationship';
|
||||||
|
|
||||||
// Soapbox
|
// Soapbox
|
||||||
|
|
|
@ -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<typeof pollSchema>;
|
||||||
|
type PollOption = Poll['options'][number];
|
||||||
|
|
||||||
|
export { pollSchema, type Poll, type PollOption };
|
|
@ -5,7 +5,7 @@ import { cardSchema } from '../card';
|
||||||
const adSchema = z.object({
|
const adSchema = z.object({
|
||||||
card: cardSchema,
|
card: cardSchema,
|
||||||
impression: z.string().optional().catch(undefined),
|
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),
|
reason: z.string().optional().catch(undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -18,8 +18,6 @@ import {
|
||||||
LocationRecord,
|
LocationRecord,
|
||||||
MentionRecord,
|
MentionRecord,
|
||||||
NotificationRecord,
|
NotificationRecord,
|
||||||
PollRecord,
|
|
||||||
PollOptionRecord,
|
|
||||||
StatusEditRecord,
|
StatusEditRecord,
|
||||||
StatusRecord,
|
StatusRecord,
|
||||||
TagRecord,
|
TagRecord,
|
||||||
|
@ -47,8 +45,6 @@ type List = ReturnType<typeof ListRecord>;
|
||||||
type Location = ReturnType<typeof LocationRecord>;
|
type Location = ReturnType<typeof LocationRecord>;
|
||||||
type Mention = ReturnType<typeof MentionRecord>;
|
type Mention = ReturnType<typeof MentionRecord>;
|
||||||
type Notification = ReturnType<typeof NotificationRecord>;
|
type Notification = ReturnType<typeof NotificationRecord>;
|
||||||
type Poll = ReturnType<typeof PollRecord>;
|
|
||||||
type PollOption = ReturnType<typeof PollOptionRecord>;
|
|
||||||
type StatusEdit = ReturnType<typeof StatusEditRecord>;
|
type StatusEdit = ReturnType<typeof StatusEditRecord>;
|
||||||
type Tag = ReturnType<typeof TagRecord>;
|
type Tag = ReturnType<typeof TagRecord>;
|
||||||
|
|
||||||
|
@ -89,8 +85,6 @@ export {
|
||||||
Location,
|
Location,
|
||||||
Mention,
|
Mention,
|
||||||
Notification,
|
Notification,
|
||||||
Poll,
|
|
||||||
PollOption,
|
|
||||||
Status,
|
Status,
|
||||||
StatusEdit,
|
StatusEdit,
|
||||||
Tag,
|
Tag,
|
||||||
|
@ -106,5 +100,7 @@ export type {
|
||||||
Group,
|
Group,
|
||||||
GroupMember,
|
GroupMember,
|
||||||
GroupRelationship,
|
GroupRelationship,
|
||||||
|
Poll,
|
||||||
|
PollOption,
|
||||||
Relationship,
|
Relationship,
|
||||||
} from 'soapbox/schemas';
|
} from 'soapbox/schemas';
|
Loading…
Reference in New Issue