Merge branch 'zod-poll' into 'develop'

zod: Poll

See merge request soapbox-pub/soapbox!2495
This commit is contained in:
Alex Gleason 2023-05-04 14:10:15 +00:00
commit da69cf140b
51 changed files with 427 additions and 705 deletions

View file

@ -1,10 +1,11 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes';
import { normalizeAccount, normalizeRelationship } from '../../normalizers';
import { normalizeAccount } from '../../normalizers';
import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes';
import type { Account } from 'soapbox/types/entities';
@ -66,7 +67,7 @@ describe('initAccountNoteModal()', () => {
beforeEach(() => {
const state = rootState
.set('relationships', ImmutableMap({ '1': normalizeRelationship({ note: 'hello' }) }));
.set('relationships', ImmutableMap({ '1': buildRelationship({ note: 'hello' }) }));
store = mockStore(state);
});

View file

@ -1,10 +1,11 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists';
import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers';
import { normalizeAccount, normalizeInstance } from '../../normalizers';
import {
authorizeFollowRequest,
blockAccount,
@ -1340,7 +1341,7 @@ describe('fetchRelationships()', () => {
describe('without newAccountIds', () => {
beforeEach(() => {
const state = rootState
.set('relationships', ImmutableMap({ [id]: normalizeRelationship({}) }))
.set('relationships', ImmutableMap({ [id]: buildRelationship() }))
.set('me', '123');
store = mockStore(state);
});

View file

@ -74,7 +74,7 @@ const importFetchedGroup = (group: APIEntity) =>
importFetchedGroups([group]);
const importFetchedGroups = (groups: APIEntity[]) => {
const entities = filteredArray(groupSchema).catch([]).parse(groups);
const entities = filteredArray(groupSchema).parse(groups);
return importGroups(entities);
};

View file

@ -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('<PollFooter />', () => {
describe('with "showResults" enabled', () => {
@ -62,10 +70,10 @@ describe('<PollFooter />', () => {
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('<PollFooter />', () => {
describe('when the Poll has expired', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
expired: true,
});
};
});
it('renders closed', () => {
@ -100,10 +108,10 @@ describe('<PollFooter />', () => {
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('<PollFooter />', () => {
describe('when the Poll is not multiple', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
multiple: false,
});
};
});
it('does not render the Vote button', () => {

View file

@ -40,21 +40,21 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
let votesCount = null;
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 {
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 (
<Stack space={4} data-testid='poll-footer'>
{(!showResults && poll?.multiple) && (
{(!showResults && poll.multiple) && (
<Button onClick={handleVote} theme='primary' block>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</Button>
)}
<HStack space={1.5} alignItems='center' wrap>
{poll.pleroma.get('non_anonymous') && (
{poll.pleroma?.non_anonymous && (
<>
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
<Text theme='muted' weight='medium'>

View file

@ -112,10 +112,13 @@ const PollOption: React.FC<IPollOption> = (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 (
<div key={option.title}>
{showResults ? (

View file

@ -6,7 +6,6 @@ import type { Card } from 'soapbox/types/entities';
/** Map of available provider modules. */
const PROVIDERS: Record<string, () => Promise<AdProvider>> = {
soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default,
rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default,
truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default,
};

View file

@ -1,58 +0,0 @@
import axios from 'axios';
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { normalizeAd, normalizeCard } from 'soapbox/normalizers';
import type { AdProvider } from '.';
/** Rumble ad API entity. */
interface RumbleAd {
type: number
impression: string
click: string
asset: string
expires: number
}
/** Response from Rumble ad server. */
interface RumbleApiResponse {
count: number
ads: RumbleAd[]
}
/** Provides ads from Soapbox Config. */
const RumbleAdProvider: AdProvider = {
getAds: async(getState) => {
const state = getState();
const settings = getSettings(state);
const soapboxConfig = getSoapboxConfig(state);
const endpoint = soapboxConfig.extensions.getIn(['ads', 'endpoint']) as string | undefined;
if (endpoint) {
try {
const { data } = await axios.get<RumbleApiResponse>(endpoint, {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
},
});
return data.ads.map(item => normalizeAd({
impression: item.impression,
card: normalizeCard({
type: item.type === 1 ? 'link' : 'rich',
image: item.asset,
url: item.click,
}),
expires_at: new Date(item.expires * 1000),
}));
} catch (e) {
// do nothing
}
}
return [];
},
};
export default RumbleAdProvider;

View file

@ -1,18 +1,19 @@
import axios from 'axios';
import { z } from 'zod';
import { getSettings } from 'soapbox/actions/settings';
import { normalizeCard } from 'soapbox/normalizers';
import { cardSchema } from 'soapbox/schemas/card';
import { filteredArray } from 'soapbox/schemas/utils';
import type { AdProvider } from '.';
import type { Card } from 'soapbox/types/entities';
/** TruthSocial ad API entity. */
interface TruthAd {
impression: string
card: Card
expires_at: string
reason: string
}
const truthAdSchema = z.object({
impression: z.string(),
card: cardSchema,
expires_at: z.string(),
reason: z.string().catch(''),
});
/** Provides ads from the TruthSocial API. */
const TruthAdProvider: AdProvider = {
@ -21,16 +22,13 @@ const TruthAdProvider: AdProvider = {
const settings = getSettings(state);
try {
const { data } = await axios.get<TruthAd[]>('/api/v2/truth/ads?device=desktop', {
const { data } = await axios.get('/api/v2/truth/ads?device=desktop', {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
'Accept-Language': z.string().catch('*').parse(settings.get('locale')),
},
});
return data.map(item => ({
...item,
card: normalizeCard(item.card),
}));
return filteredArray(truthAdSchema).parse(data);
} catch (e) {
// do nothing
}

View file

@ -1,12 +1,10 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction';
import { render, screen } from '../../../../jest/test-helpers';
import ChatMessageReaction from '../chat-message-reaction';
const emojiReaction = normalizeEmojiReaction({
const emojiReaction = ({
name: '👍',
count: 1,
me: false,
@ -56,7 +54,7 @@ describe('<ChatMessageReaction />', () => {
render(
<ChatMessageReaction
emojiReaction={normalizeEmojiReaction({
emojiReaction={({
name: '👍',
count: 1,
me: true,

View file

@ -312,7 +312,7 @@ const ChatMessage = (props: IChatMessage) => {
</Stack>
</HStack>
{(chatMessage.emoji_reactions?.size) ? (
{(chatMessage.emoji_reactions?.length) ? (
<div
className={clsx({
'space-y-1': true,

View file

@ -2,6 +2,7 @@ import React from 'react';
import { buildGroup, buildGroupRelationship } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { Group } from 'soapbox/types/entities';
import GroupActionButton from '../group-action-button';
@ -45,7 +46,7 @@ describe('<GroupActionButton />', () => {
beforeEach(() => {
group = buildGroup({
relationship: buildGroupRelationship({
member: null,
member: false,
}),
});
});
@ -98,7 +99,7 @@ describe('<GroupActionButton />', () => {
relationship: buildGroupRelationship({
requested: false,
member: true,
role: 'owner',
role: GroupRoles.OWNER,
}),
});
});
@ -116,7 +117,7 @@ describe('<GroupActionButton />', () => {
relationship: buildGroupRelationship({
requested: false,
member: true,
role: 'user',
role: GroupRoles.USER,
}),
});
});

View file

@ -17,7 +17,7 @@ describe('<GroupOptionsButton />', () => {
requested: false,
member: true,
blocked_by: true,
role: 'user',
role: GroupRoles.USER,
}),
});
});

View file

@ -1,8 +1,9 @@
// import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import { normalizeAccount, normalizeRelationship } from '../../../../normalizers';
import { buildRelationship } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { normalizeAccount } from 'soapbox/normalizers';
import SubscribeButton from '../subscription-button';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
@ -19,162 +20,10 @@ describe('<SubscribeButton />', () => {
describe('with "accountNotifies" disabled', () => {
it('renders nothing', () => {
const account = normalizeAccount({ ...justin, relationship: normalizeRelationship({ following: true }) }) as ReducerAccount;
const account = normalizeAccount({ ...justin, relationship: buildRelationship({ following: true }) }) as ReducerAccount;
render(<SubscribeButton account={account} />, undefined, store);
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
// describe('with "accountNotifies" enabled', () => {
// beforeEach(() => {
// store = {
// ...store,
// instance: normalizeInstance({
// version: '3.4.1 (compatible; TruthSocial 1.0.0)',
// software: 'TRUTHSOCIAL',
// pleroma: ImmutableMap({}),
// }),
// };
// });
// describe('when the relationship is requested', () => {
// beforeEach(() => {
// account = normalizeAccount({ ...account, relationship: normalizeRelationship({ requested: true }) });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button')).toBeInTheDocument();
// });
// describe('when the user "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: true }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Unsubscribe to notifications from @${account.acct}`);
// });
// });
// describe('when the user is not "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: false }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`);
// });
// });
// });
// describe('when the user is not following the account', () => {
// beforeEach(() => {
// account = normalizeAccount({ ...account, relationship: normalizeRelationship({ following: false }) });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders nothing', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
// });
// });
// describe('when the user is following the account', () => {
// beforeEach(() => {
// account = normalizeAccount({ ...account, relationship: normalizeRelationship({ following: true }) });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button')).toBeInTheDocument();
// });
// describe('when the user "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: true }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Unsubscribe to notifications from @${account.acct}`);
// });
// });
// describe('when the user is not "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: false }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`);
// });
// });
// });
// });
});

View file

@ -1,33 +1,64 @@
import { v4 as uuidv4 } from 'uuid';
import {
adSchema,
cardSchema,
groupSchema,
groupRelationshipSchema,
groupTagSchema,
relationshipSchema,
type Ad,
type Card,
type Group,
type GroupRelationship,
type GroupTag,
type Relationship,
} from 'soapbox/schemas';
// TODO: there's probably a better way to create these factory functions.
// This looks promising but didn't work on my first attempt: https://github.com/anatine/zod-plugins/tree/main/packages/zod-mock
function buildGroup(props: Record<string, any> = {}): Group {
function buildCard(props: Partial<Card> = {}): Card {
return cardSchema.parse(Object.assign({
url: 'https://soapbox.test',
}, props));
}
function buildGroup(props: Partial<Group> = {}): Group {
return groupSchema.parse(Object.assign({
id: uuidv4(),
}, props));
}
function buildGroupRelationship(props: Record<string, any> = {}): GroupRelationship {
function buildGroupRelationship(props: Partial<GroupRelationship> = {}): GroupRelationship {
return groupRelationshipSchema.parse(Object.assign({
id: uuidv4(),
}, props));
}
function buildGroupTag(props: Record<string, any> = {}): GroupTag {
function buildGroupTag(props: Partial<GroupTag> = {}): GroupTag {
return groupTagSchema.parse(Object.assign({
id: uuidv4(),
}, props));
}
export { buildGroup, buildGroupRelationship, buildGroupTag };
function buildAd(props: Partial<Ad> = {}): Ad {
return adSchema.parse(Object.assign({
card: buildCard(),
}, props));
}
function buildRelationship(props: Partial<Relationship> = {}): Relationship {
return relationshipSchema.parse(Object.assign({
id: uuidv4(),
}, props));
}
export {
buildCard,
buildGroup,
buildGroupRelationship,
buildGroupTag,
buildAd,
buildRelationship,
};

View file

@ -1,14 +0,0 @@
import { Record as ImmutableRecord } from 'immutable';
import { normalizeCard } from '../card';
describe('normalizeCard()', () => {
it('adds base fields', () => {
const card = {};
const result = normalizeCard(card);
expect(ImmutableRecord.isRecord(result)).toBe(true);
expect(result.type).toEqual('link');
expect(result.url).toEqual('');
});
});

View file

@ -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');
});
});

View file

@ -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', () => {
@ -195,7 +195,6 @@ describe('normalizeStatus()', () => {
const result = normalizeStatus(status);
const card = result.card as Card;
expect(ImmutableRecord.isRecord(card)).toBe(true);
expect(card.type).toEqual('link');
expect(card.provider_url).toEqual('https://soapbox.pub');
});

View file

@ -1,82 +0,0 @@
/**
* Card normalizer:
* Converts API cards into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/card/}
*/
import punycode from 'punycode';
import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable';
import { groupSchema, type Group } from 'soapbox/schemas';
import { mergeDefined } from 'soapbox/utils/normalizers';
// https://docs.joinmastodon.org/entities/card/
export const CardRecord = ImmutableRecord({
author_name: '',
author_url: '',
blurhash: null as string | null,
description: '',
embed_url: '',
group: null as null | Group,
height: 0,
html: '',
image: null as string | null,
provider_name: '',
provider_url: '',
title: '',
type: 'link',
url: '',
width: 0,
});
const IDNA_PREFIX = 'xn--';
const decodeIDNA = (domain: string): string => {
return domain
.split('.')
.map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
.join('.');
};
const getHostname = (url: string): string => {
const parser = document.createElement('a');
parser.href = url;
return parser.hostname;
};
/** Fall back to Pleroma's OG data */
const normalizePleromaOpengraph = (card: ImmutableMap<string, any>) => {
const opengraph = ImmutableMap({
width: card.getIn(['pleroma', 'opengraph', 'width']),
height: card.getIn(['pleroma', 'opengraph', 'height']),
html: card.getIn(['pleroma', 'opengraph', 'html']),
image: card.getIn(['pleroma', 'opengraph', 'thumbnail_url']),
});
return card.mergeWith(mergeDefined, opengraph);
};
/** Set provider from URL if not found */
const normalizeProviderName = (card: ImmutableMap<string, any>) => {
const providerName = card.get('provider_name') || decodeIDNA(getHostname(card.get('url')));
return card.set('provider_name', providerName);
};
const normalizeGroup = (card: ImmutableMap<string, any>) => {
try {
const group = groupSchema.parse(card.get('group').toJS());
return card.set('group', group);
} catch (_e) {
return card.set('group', null);
}
};
export const normalizeCard = (card: Record<string, any>) => {
return CardRecord(
ImmutableMap(fromJS(card)).withMutations(card => {
normalizePleromaOpengraph(card);
normalizeProviderName(card);
normalizeGroup(card);
}),
);
};

View file

@ -6,8 +6,8 @@ import {
} from 'immutable';
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeEmojiReaction } from './emoji-reaction';
import { emojiReactionSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import type { Attachment, Card, Emoji, EmojiReaction } from 'soapbox/types/entities';
@ -20,7 +20,7 @@ export const ChatMessageRecord = ImmutableRecord({
created_at: '',
emojis: ImmutableList<Emoji>(),
expiration: null as number | null,
emoji_reactions: null as ImmutableList<EmojiReaction> | null,
emoji_reactions: null as readonly EmojiReaction[] | null,
id: '',
unread: false,
deleting: false,
@ -41,13 +41,8 @@ const normalizeMedia = (status: ImmutableMap<string, any>) => {
};
const normalizeChatMessageEmojiReaction = (chatMessage: ImmutableMap<string, any>) => {
const emojiReactions = chatMessage.get('emoji_reactions');
if (emojiReactions) {
return chatMessage.set('emoji_reactions', ImmutableList(emojiReactions.map(normalizeEmojiReaction)));
} else {
return chatMessage;
}
const emojiReactions = ImmutableList(chatMessage.get('emoji_reactions') || []);
return chatMessage.set('emoji_reactions', filteredArray(emojiReactionSchema).parse(emojiReactions.toJS()));
};
/** Rewrite `<p></p>` to empty string. */

View file

@ -1,14 +0,0 @@
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
// https://docs.joinmastodon.org/entities/emoji/
export const EmojiReactionRecord = ImmutableRecord({
name: '',
count: null as number | null,
me: false,
});
export const normalizeEmojiReaction = (emojiReaction: Record<string, any>) => {
return EmojiReactionRecord(
ImmutableMap(fromJS(emojiReaction)),
);
};

View file

@ -4,11 +4,9 @@ export { AdminReportRecord, normalizeAdminReport } from './admin-report';
export { AnnouncementRecord, normalizeAnnouncement } from './announcement';
export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement-reaction';
export { AttachmentRecord, normalizeAttachment } from './attachment';
export { CardRecord, normalizeCard } from './card';
export { ChatRecord, normalizeChat } from './chat';
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
export { EmojiRecord, normalizeEmoji } from './emoji';
export { EmojiReactionRecord } from './emoji-reaction';
export { FilterRecord, normalizeFilter } from './filter';
export { FilterKeywordRecord, normalizeFilterKeyword } from './filter-keyword';
export { FilterStatusRecord, normalizeFilterStatus } from './filter-status';
@ -20,11 +18,8 @@ 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 { RelationshipRecord, normalizeRelationship } from './relationship';
export { StatusRecord, normalizeStatus } from './status';
export { StatusEditRecord, normalizeStatusEdit } from './status-edit';
export { TagRecord, normalizeTag } from './tag';
export { AdRecord, normalizeAd } from './soapbox/ad';
export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox-config';

View file

@ -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);
}),
);
};

View file

@ -1,35 +0,0 @@
/**
* Relationship normalizer:
* Converts API relationships into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/relationship/}
*/
import {
Map as ImmutableMap,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
// https://docs.joinmastodon.org/entities/relationship/
// https://api.pleroma.social/#operation/AccountController.relationships
export const RelationshipRecord = ImmutableRecord({
blocked_by: false,
blocking: false,
domain_blocking: false,
endorsed: false,
followed_by: false,
following: false,
id: '',
muting: false,
muting_notifications: false,
note: '',
notifying: false,
requested: false,
showing_reblogs: false,
subscribing: false,
});
export const normalizeRelationship = (relationship: Record<string, any>) => {
return RelationshipRecord(
ImmutableMap(fromJS(relationship)),
);
};

View file

@ -1,28 +0,0 @@
import {
Map as ImmutableMap,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
import { CardRecord, normalizeCard } from '../card';
import type { Ad } from 'soapbox/features/ads/providers';
export const AdRecord = ImmutableRecord<Ad>({
card: CardRecord(),
impression: undefined as string | undefined,
expires_at: undefined as string | undefined,
reason: undefined as string | undefined,
});
/** Normalizes an ad from Soapbox Config. */
export const normalizeAd = (ad: Record<string, any>) => {
const map = ImmutableMap<string, any>(fromJS(ad));
const card = normalizeCard(map.get('card'));
const expiresAt = map.get('expires_at') || map.get('expires');
return AdRecord(map.merge({
card,
expires_at: expiresAt,
}));
};

View file

@ -6,12 +6,12 @@ import {
} from 'immutable';
import trimStart from 'lodash/trimStart';
import { adSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { normalizeUsername } from 'soapbox/utils/input';
import { toTailwind } from 'soapbox/utils/tailwind';
import { generateAccent } from 'soapbox/utils/theme';
import { normalizeAd } from './ad';
import type {
Ad,
PromoPanelItem,
@ -125,8 +125,12 @@ export const SoapboxConfigRecord = ImmutableRecord({
type SoapboxConfigMap = ImmutableMap<string, any>;
const normalizeAds = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
const ads = ImmutableList<Record<string, any>>(soapboxConfig.get('ads'));
return soapboxConfig.set('ads', ads.map(normalizeAd));
if (soapboxConfig.has('ads')) {
const ads = filteredArray(adSchema).parse(soapboxConfig.get('ads').toJS());
return soapboxConfig.set('ads', ads);
} else {
return soapboxConfig;
}
};
const normalizeCryptoAddress = (address: unknown): CryptoAddress => {

View file

@ -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<string, any>) => {
// Normalize the poll in the status, if applicable
const normalizeStatusPoll = (statusEdit: ImmutableMap<string, any>) => {
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);
}
};

View file

@ -11,10 +11,9 @@ import {
} from 'immutable';
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeCard } from 'soapbox/normalizers/card';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizeMention } from 'soapbox/normalizers/mention';
import { normalizePoll } from 'soapbox/normalizers/poll';
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,18 +108,20 @@ const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
// Normalize the poll in the status, if applicable
const normalizeStatusPoll = (status: ImmutableMap<string, any>) => {
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);
}
};
// Normalize card
const normalizeStatusCard = (status: ImmutableMap<string, any>) => {
if (status.get('card')) {
return status.update('card', ImmutableMap(), normalizeCard);
} else {
try {
const card = cardSchema.parse(status.get('card').toJS());
return status.set('card', card);
} catch (e) {
return status.set('card', null);
}
};

View file

@ -1,11 +1,11 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { Map as ImmutableMap } from 'immutable';
import sumBy from 'lodash/sumBy';
import { useEffect } from 'react';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { createTestStore, mockStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers';
import { normalizeChatMessage, normalizeRelationship } from 'soapbox/normalizers';
import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction';
import { normalizeChatMessage } from 'soapbox/normalizers';
import { Store } from 'soapbox/store';
import { ChatMessage } from 'soapbox/types/entities';
import { flattenPages } from 'soapbox/utils/queries';
@ -120,7 +120,7 @@ describe('useChatMessages', () => {
const state = rootState
.set(
'relationships',
ImmutableMap({ '1': normalizeRelationship({ blocked_by: true }) }),
ImmutableMap({ '1': buildRelationship({ blocked_by: true }) }),
);
store = mockStore(state);
});
@ -239,7 +239,7 @@ describe('useChat()', () => {
mock.onGet(`/api/v1/pleroma/chats/${chat.id}`).reply(200, chat);
mock
.onGet(`/api/v1/accounts/relationships?id[]=${chat.account.id}`)
.reply(200, [normalizeRelationship({ id: relationshipId, blocked_by: true })]);
.reply(200, [buildRelationship({ id: relationshipId, blocked_by: true })]);
});
});
@ -425,11 +425,11 @@ describe('useChatActions', () => {
});
const updatedChatMessage = (queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0] as ChatMessage;
expect(updatedChatMessage.emoji_reactions).toEqual(ImmutableList([normalizeEmojiReaction({
expect(updatedChatMessage.emoji_reactions).toEqual([{
name: '👍',
count: 1,
me: true,
})]));
}]);
});
});
});

View file

@ -1,8 +1,8 @@
import { useEffect } from 'react';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { createTestStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers';
import { normalizeRelationship } from 'soapbox/normalizers';
import { Store } from 'soapbox/store';
import { useFetchRelationships } from '../relationships';
@ -25,7 +25,7 @@ describe('useFetchRelationships()', () => {
__stub((mock) => {
mock
.onGet(`/api/v1/accounts/relationships?id[]=${id}`)
.reply(200, [normalizeRelationship({ id, blocked_by: true })]);
.reply(200, [buildRelationship({ id, blocked_by: true })]);
});
});
@ -55,7 +55,7 @@ describe('useFetchRelationships()', () => {
__stub((mock) => {
mock
.onGet(`/api/v1/accounts/relationships?id[]=${ids[0]}&id[]=${ids[1]}`)
.reply(200, ids.map((id) => normalizeRelationship({ id, blocked_by: true })));
.reply(200, ids.map((id) => buildRelationship({ id, blocked_by: true })));
});
});

View file

@ -2,7 +2,8 @@ import { useQuery } from '@tanstack/react-query';
import { Ad, getProvider } from 'soapbox/features/ads/providers';
import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAd } from 'soapbox/normalizers';
import { adSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { isExpired } from 'soapbox/utils/ads';
const AdKeys = {
@ -28,7 +29,9 @@ function useAds() {
});
// Filter out expired ads.
const data = result.data?.map(normalizeAd).filter(ad => !isExpired(ad));
const data = filteredArray(adSchema)
.parse(result.data)
.filter(ad => !isExpired(ad));
return {
...result,

View file

@ -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,

View file

@ -2,7 +2,7 @@ import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import get from 'lodash/get';
import { STREAMING_FOLLOW_RELATIONSHIPS_UPDATE } from 'soapbox/actions/streaming';
import { normalizeRelationship } from 'soapbox/normalizers/relationship';
import { type Relationship, relationshipSchema } from 'soapbox/schemas';
import { ACCOUNT_NOTE_SUBMIT_SUCCESS } from '../actions/account-notes';
import {
@ -35,13 +35,16 @@ import {
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
type Relationship = ReturnType<typeof normalizeRelationship>;
type State = ImmutableMap<string, Relationship>;
type APIEntities = Array<APIEntity>;
const normalizeRelationships = (state: State, relationships: APIEntities) => {
relationships.forEach(relationship => {
state = state.set(relationship.id, normalizeRelationship(relationship));
try {
state = state.set(relationship.id, relationshipSchema.parse(relationship));
} catch (_e) {
// do nothing
}
});
return state;
@ -84,8 +87,12 @@ const followStateToRelationship = (followState: string) => {
};
const updateFollowRelationship = (state: State, id: string, followState: string) => {
const map = followStateToRelationship(followState);
return state.update(id, normalizeRelationship({}), relationship => relationship.merge(map));
const relationship = state.get(id) || relationshipSchema.parse({ id });
return state.set(id, {
...relationship,
...followStateToRelationship(followState),
});
};
export default function relationships(state: State = ImmutableMap<string, Relationship>(), action: AnyAction) {

View file

@ -74,11 +74,11 @@ const minifyStatus = (status: StatusRecord): ReducerStatus => {
};
// Gets titles of poll options from status
const getPollOptionTitles = ({ poll }: StatusRecord): ImmutableList<string> => {
const getPollOptionTitles = ({ poll }: StatusRecord): readonly string[] => {
if (poll && typeof poll === 'object') {
return poll.options.map(({ title }) => title);
} else {
return ImmutableList();
return [];
}
};

View file

@ -0,0 +1,11 @@
import { cardSchema } from '../card';
describe('cardSchema', () => {
it('adds base fields', () => {
const card = { url: 'https://soapbox.test' };
const result = cardSchema.parse(card);
expect(result.type).toEqual('link');
expect(result.url).toEqual(card.url);
});
});

View file

@ -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');
});
});

View file

@ -22,7 +22,7 @@ const accountSchema = z.object({
created_at: z.string().datetime().catch(new Date().toUTCString()),
discoverable: z.boolean().catch(false),
display_name: z.string().catch(''),
emojis: filteredArray(customEmojiSchema).catch([]),
emojis: filteredArray(customEmojiSchema),
favicon: z.string().catch(''),
fields: z.any(), // TODO
followers_count: z.number().catch(0),
@ -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(''),

View file

@ -0,0 +1,69 @@
import punycode from 'punycode';
import { z } from 'zod';
import { groupSchema } from './group';
const IDNA_PREFIX = 'xn--';
/**
* Card (aka link preview).
* https://docs.joinmastodon.org/entities/card/
*/
const cardSchema = z.object({
author_name: z.string().catch(''),
author_url: z.string().url().catch(''),
blurhash: z.string().nullable().catch(null),
description: z.string().catch(''),
embed_url: z.string().url().catch(''),
group: groupSchema.nullable().catch(null), // TruthSocial
height: z.number().catch(0),
html: z.string().catch(''),
image: z.string().nullable().catch(null),
pleroma: z.object({
opengraph: z.object({
width: z.number(),
height: z.number(),
html: z.string(),
thumbnail_url: z.string().url(),
}).optional().catch(undefined),
}).optional().catch(undefined),
provider_name: z.string().catch(''),
provider_url: z.string().url().catch(''),
title: z.string().catch(''),
type: z.enum(['link', 'photo', 'video', 'rich']).catch('link'),
url: z.string().url(),
width: z.number().catch(0),
}).transform(({ pleroma, ...card }) => {
if (!card.provider_name) {
card.provider_name = decodeIDNA(new URL(card.url).hostname);
}
if (pleroma?.opengraph) {
if (!card.width && !card.height) {
card.width = pleroma.opengraph.width;
card.height = pleroma.opengraph.height;
}
if (!card.html) {
card.html = pleroma.opengraph.html;
}
if (!card.image) {
card.image = pleroma.opengraph.thumbnail_url;
}
}
return card;
});
const decodeIDNA = (domain: string): string => {
return domain
.split('.')
.map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
.join('.');
};
type Card = z.infer<typeof cardSchema>;
export { cardSchema, type Card };

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
/** Validates the string as an emoji. */
const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v));
/** Pleroma emoji reaction. */
const emojiReactionSchema = z.object({
name: emojiSchema,
count: z.number().nullable().catch(null),
me: z.boolean().catch(false),
});
type EmojiReaction = z.infer<typeof emojiReactionSchema>;
export { emojiReactionSchema, EmojiReaction };

View file

@ -19,7 +19,7 @@ const groupSchema = z.object({
deleted_at: z.string().datetime().or(z.null()).catch(null),
display_name: z.string().catch(''),
domain: z.string().catch(''),
emojis: filteredArray(customEmojiSchema).catch([]),
emojis: filteredArray(customEmojiSchema),
group_visibility: z.string().catch(''), // TruthSocial
header: z.string().catch(headerMissing),
header_static: z.string().catch(''),

View file

@ -1,21 +1,14 @@
/**
* Schemas
*/
export { accountSchema } from './account';
export { customEmojiSchema } from './custom-emoji';
export { groupSchema } from './group';
export { groupMemberSchema } from './group-member';
export { groupRelationshipSchema } from './group-relationship';
export { groupTagSchema } from './group-tag';
export { relationshipSchema } from './relationship';
export { accountSchema, type Account } from './account';
export { cardSchema, type Card } from './card';
export { customEmojiSchema, type CustomEmoji } from './custom-emoji';
export { emojiReactionSchema, type EmojiReaction } from './emoji-reaction';
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';
export { tagSchema, type Tag } from './tag';
/**
* Entity Types
*/
export type { Account } from './account';
export type { CustomEmoji } from './custom-emoji';
export type { Group } from './group';
export type { GroupMember } from './group-member';
export type { GroupRelationship } from './group-relationship';
export type { GroupTag } from './group-tag';
export type { Relationship } from './relationship';
// Soapbox
export { adSchema, type Ad } from './soapbox/ad';

View file

@ -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 };

View file

@ -19,4 +19,4 @@ const relationshipSchema = z.object({
type Relationship = z.infer<typeof relationshipSchema>;
export { relationshipSchema, Relationship };
export { relationshipSchema, type Relationship };

View file

@ -0,0 +1,14 @@
import { z } from 'zod';
import { cardSchema } from '../card';
const adSchema = z.object({
card: cardSchema,
impression: z.string().optional().catch(undefined),
expires_at: z.string().datetime().optional().catch(undefined),
reason: z.string().optional().catch(undefined),
});
type Ad = z.infer<typeof adSchema>;
export { adSchema, type Ad };

View file

@ -0,0 +1,18 @@
import { z } from 'zod';
const historySchema = z.object({
accounts: z.coerce.number(),
uses: z.coerce.number(),
});
/** // https://docs.joinmastodon.org/entities/tag */
const tagSchema = z.object({
name: z.string().min(1),
url: z.string().url().catch(''),
history: z.array(historySchema).nullable().catch(null),
following: z.boolean().catch(false),
});
type Tag = z.infer<typeof tagSchema>;
export { tagSchema, type Tag };

View file

@ -4,7 +4,7 @@ import type { CustomEmoji } from './custom-emoji';
/** Validates individual items in an array, dropping any that aren't valid. */
function filteredArray<T extends z.ZodTypeAny>(schema: T) {
return z.any().array()
return z.any().array().catch([])
.transform((arr) => (
arr.map((item) => {
const parsed = schema.safeParse(item);

View file

@ -5,11 +5,9 @@ import {
AnnouncementRecord,
AnnouncementReactionRecord,
AttachmentRecord,
CardRecord,
ChatRecord,
ChatMessageRecord,
EmojiRecord,
EmojiReactionRecord,
FieldRecord,
FilterRecord,
FilterKeywordRecord,
@ -20,9 +18,6 @@ import {
LocationRecord,
MentionRecord,
NotificationRecord,
PollRecord,
PollOptionRecord,
RelationshipRecord,
StatusEditRecord,
StatusRecord,
TagRecord,
@ -37,11 +32,9 @@ type AdminReport = ReturnType<typeof AdminReportRecord>;
type Announcement = ReturnType<typeof AnnouncementRecord>;
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
type Attachment = ReturnType<typeof AttachmentRecord>;
type Card = ReturnType<typeof CardRecord>;
type Chat = ReturnType<typeof ChatRecord>;
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
type Emoji = ReturnType<typeof EmojiRecord>;
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
type Field = ReturnType<typeof FieldRecord>;
type Filter = ReturnType<typeof FilterRecord>;
type FilterKeyword = ReturnType<typeof FilterKeywordRecord>;
@ -52,9 +45,6 @@ type List = ReturnType<typeof ListRecord>;
type Location = ReturnType<typeof LocationRecord>;
type Mention = ReturnType<typeof MentionRecord>;
type Notification = ReturnType<typeof NotificationRecord>;
type Poll = ReturnType<typeof PollRecord>;
type PollOption = ReturnType<typeof PollOptionRecord>;
type Relationship = ReturnType<typeof RelationshipRecord>;
type StatusEdit = ReturnType<typeof StatusEditRecord>;
type Tag = ReturnType<typeof TagRecord>;
@ -82,11 +72,9 @@ export {
Announcement,
AnnouncementReaction,
Attachment,
Card,
Chat,
ChatMessage,
Emoji,
EmojiReaction,
Field,
Filter,
FilterKeyword,
@ -97,9 +85,6 @@ export {
Location,
Mention,
Notification,
Poll,
PollOption,
Relationship,
Status,
StatusEdit,
Tag,
@ -110,7 +95,12 @@ export {
};
export type {
Card,
EmojiReaction,
Group,
GroupMember,
GroupRelationship,
Poll,
PollOption,
Relationship,
} from 'soapbox/schemas';

View file

@ -1,4 +1,3 @@
import { AdRecord } from 'soapbox/normalizers/soapbox/ad';
import {
PromoPanelItemRecord,
FooterItemRecord,
@ -8,7 +7,6 @@ import {
type Me = string | null | false | undefined;
type Ad = ReturnType<typeof AdRecord>;
type PromoPanelItem = ReturnType<typeof PromoPanelItemRecord>;
type FooterItem = ReturnType<typeof FooterItemRecord>;
type CryptoAddress = ReturnType<typeof CryptoAddressRecord>;
@ -16,9 +14,12 @@ type SoapboxConfig = ReturnType<typeof SoapboxConfigRecord>;
export {
Me,
Ad,
PromoPanelItem,
FooterItem,
CryptoAddress,
SoapboxConfig,
};
export type {
Ad,
} from 'soapbox/schemas';

View file

@ -1,4 +1,4 @@
import { normalizeAd } from 'soapbox/normalizers';
import { buildAd } from 'soapbox/jest/factory';
import { isExpired } from '../ads';
@ -14,10 +14,10 @@ test('isExpired()', () => {
const epoch = now.getTime();
// Sanity tests.
expect(isExpired(normalizeAd({ expires_at: iso }))).toBe(true);
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false);
expect(isExpired(buildAd({ expires_at: iso }))).toBe(true);
expect(isExpired(buildAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false);
// Testing the 5-minute mark.
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true);
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false);
expect(isExpired(buildAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true);
expect(isExpired(buildAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false);
});

View file

@ -1,4 +1,4 @@
import type { Ad } from 'soapbox/types/soapbox';
import type { Ad } from 'soapbox/schemas';
/** Time (ms) window to not display an ad if it's about to expire. */
const AD_EXPIRY_THRESHOLD = 5 * 60 * 1000;

View file

@ -261,7 +261,7 @@ const getInstanceFeatures = (instance: Instance) => {
/**
* Ability to add reactions to chat messages.
*/
chatEmojiReactions: v.software === TRUTHSOCIAL && v.build === UNRELEASED,
chatEmojiReactions: v.software === TRUTHSOCIAL,
/**
* Pleroma chats API.