From 741da9208466d3c8f9a18b03de745b655fe76034 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 2 May 2023 17:47:19 -0500 Subject: [PATCH 01/10] Card: normalize with zod --- app/soapbox/normalizers/card.ts | 97 ++++++--------------------- app/soapbox/normalizers/index.ts | 2 +- app/soapbox/normalizers/soapbox/ad.ts | 6 +- app/soapbox/normalizers/status.ts | 9 +-- app/soapbox/schemas/card.ts | 69 +++++++++++++++++++ app/soapbox/schemas/index.ts | 1 + app/soapbox/types/entities.ts | 4 +- 7 files changed, 100 insertions(+), 88 deletions(-) create mode 100644 app/soapbox/schemas/card.ts diff --git a/app/soapbox/normalizers/card.ts b/app/soapbox/normalizers/card.ts index 5d0af42cf..c29a0c40b 100644 --- a/app/soapbox/normalizers/card.ts +++ b/app/soapbox/normalizers/card.ts @@ -1,82 +1,25 @@ -/** - * Card normalizer: - * Converts API cards into our internal format. - * @see {@link https://docs.joinmastodon.org/entities/card/} - */ -import punycode from 'punycode'; +import { cardSchema, type Card } from 'soapbox/schemas/card'; -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) => { - 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) => { - const providerName = card.get('provider_name') || decodeIDNA(getHostname(card.get('url'))); - return card.set('provider_name', providerName); -}; - -const normalizeGroup = (card: ImmutableMap) => { +export const normalizeCard = (card: unknown): Card => { try { - const group = groupSchema.parse(card.get('group').toJS()); - return card.set('group', group); + return cardSchema.parse(card); } catch (_e) { - return card.set('group', null); + return { + author_name: '', + author_url: '', + blurhash: null, + description: '', + embed_url: '', + group: null, + height: 0, + html: '', + image: null, + provider_name: '', + provider_url: '', + title: '', + type: 'link', + url: '', + width: 0, + }; } }; - -export const normalizeCard = (card: Record) => { - return CardRecord( - ImmutableMap(fromJS(card)).withMutations(card => { - normalizePleromaOpengraph(card); - normalizeProviderName(card); - normalizeGroup(card); - }), - ); -}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 004049988..7f1d76a01 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -4,7 +4,7 @@ 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 { normalizeCard } from './card'; export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat-message'; export { EmojiRecord, normalizeEmoji } from './emoji'; diff --git a/app/soapbox/normalizers/soapbox/ad.ts b/app/soapbox/normalizers/soapbox/ad.ts index 85dbcc8c6..10ae96d60 100644 --- a/app/soapbox/normalizers/soapbox/ad.ts +++ b/app/soapbox/normalizers/soapbox/ad.ts @@ -4,12 +4,12 @@ import { fromJS, } from 'immutable'; -import { CardRecord, normalizeCard } from '../card'; +import { normalizeCard } from '../card'; import type { Ad } from 'soapbox/features/ads/providers'; export const AdRecord = ImmutableRecord({ - card: CardRecord(), + card: normalizeCard({}), impression: undefined as string | undefined, expires_at: undefined as string | undefined, reason: undefined as string | undefined, @@ -18,7 +18,7 @@ export const AdRecord = ImmutableRecord({ /** Normalizes an ad from Soapbox Config. */ export const normalizeAd = (ad: Record) => { const map = ImmutableMap(fromJS(ad)); - const card = normalizeCard(map.get('card')); + const card = normalizeCard(map.get('card').toJS()); const expiresAt = map.get('expires_at') || map.get('expires'); return AdRecord(map.merge({ diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 7a71f24a8..64a00b316 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -11,10 +11,10 @@ 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 } from 'soapbox/schemas/card'; import type { ReducerAccount } from 'soapbox/reducers/accounts'; import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities'; @@ -118,9 +118,10 @@ const normalizeStatusPoll = (status: ImmutableMap) => { // Normalize card const normalizeStatusCard = (status: ImmutableMap) => { - 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); } }; diff --git a/app/soapbox/schemas/card.ts b/app/soapbox/schemas/card.ts new file mode 100644 index 000000000..c3826fa1d --- /dev/null +++ b/app/soapbox/schemas/card.ts @@ -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((card) => { + if (!card.provider_name) { + card.provider_name = decodeIDNA(new URL(card.url).hostname); + } + + if (card.pleroma?.opengraph) { + if (!card.width && !card.height) { + card.width = card.pleroma.opengraph.width; + card.height = card.pleroma.opengraph.height; + } + + if (!card.html) { + card.html = card.pleroma.opengraph.html; + } + + if (!card.image) { + card.image = card.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; + +export { cardSchema, type Card }; \ No newline at end of file diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts index a675b52d2..23f6067c8 100644 --- a/app/soapbox/schemas/index.ts +++ b/app/soapbox/schemas/index.ts @@ -13,6 +13,7 @@ export { relationshipSchema } from './relationship'; * Entity Types */ export type { Account } from './account'; +export type { Card } from './card'; export type { CustomEmoji } from './custom-emoji'; export type { Group } from './group'; export type { GroupMember } from './group-member'; diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index c4d8907c5..ebbdd197d 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -5,7 +5,6 @@ import { AnnouncementRecord, AnnouncementReactionRecord, AttachmentRecord, - CardRecord, ChatRecord, ChatMessageRecord, EmojiRecord, @@ -37,7 +36,6 @@ type AdminReport = ReturnType; type Announcement = ReturnType; type AnnouncementReaction = ReturnType; type Attachment = ReturnType; -type Card = ReturnType; type Chat = ReturnType; type ChatMessage = ReturnType; type Emoji = ReturnType; @@ -82,7 +80,6 @@ export { Announcement, AnnouncementReaction, Attachment, - Card, Chat, ChatMessage, Emoji, @@ -110,6 +107,7 @@ export { }; export type { + Card, Group, GroupMember, GroupRelationship, From 81de7c268eec7d1c86e6c78d86594f4387cca346 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 2 May 2023 17:56:25 -0500 Subject: [PATCH 02/10] Refactor ad providers to not use normalizeCard --- app/soapbox/features/ads/providers/index.ts | 1 - app/soapbox/features/ads/providers/rumble.ts | 58 -------------------- app/soapbox/features/ads/providers/truth.ts | 26 ++++----- 3 files changed, 12 insertions(+), 73 deletions(-) delete mode 100644 app/soapbox/features/ads/providers/rumble.ts diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index 8ff7d5219..63067b81d 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -6,7 +6,6 @@ import type { Card } from 'soapbox/types/entities'; /** Map of available provider modules. */ const PROVIDERS: Record Promise> = { 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, }; diff --git a/app/soapbox/features/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts deleted file mode 100644 index 21dc6e7f3..000000000 --- a/app/soapbox/features/ads/providers/rumble.ts +++ /dev/null @@ -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(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; diff --git a/app/soapbox/features/ads/providers/truth.ts b/app/soapbox/features/ads/providers/truth.ts index 9207db522..5582bd3cf 100644 --- a/app/soapbox/features/ads/providers/truth.ts +++ b/app/soapbox/features/ads/providers/truth.ts @@ -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('/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 } From 54d8d120549598f0d8b693142571dec7fb2baa6a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 2 May 2023 18:30:21 -0500 Subject: [PATCH 03/10] Remove normalizeAd --- app/soapbox/actions/importer/index.ts | 2 +- .../__tests__/group-action-button.test.tsx | 7 +++-- .../__tests__/group-options-button.test.tsx | 2 +- app/soapbox/jest/factory.ts | 24 +++++++++++--- app/soapbox/normalizers/index.ts | 1 - app/soapbox/normalizers/soapbox/ad.ts | 28 ----------------- .../normalizers/soapbox/soapbox-config.ts | 12 ++++--- app/soapbox/queries/ads.ts | 7 +++-- app/soapbox/schemas/account.ts | 2 +- app/soapbox/schemas/group.ts | 2 +- app/soapbox/schemas/index.ts | 31 ++++++------------- app/soapbox/schemas/soapbox/ad.ts | 14 +++++++++ app/soapbox/schemas/utils.ts | 2 +- app/soapbox/types/soapbox.ts | 7 +++-- app/soapbox/utils/__tests__/ads.test.ts | 10 +++--- app/soapbox/utils/ads.ts | 2 +- 16 files changed, 76 insertions(+), 77 deletions(-) delete mode 100644 app/soapbox/normalizers/soapbox/ad.ts create mode 100644 app/soapbox/schemas/soapbox/ad.ts diff --git a/app/soapbox/actions/importer/index.ts b/app/soapbox/actions/importer/index.ts index ec0ec3121..fc9ad63bd 100644 --- a/app/soapbox/actions/importer/index.ts +++ b/app/soapbox/actions/importer/index.ts @@ -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); }; diff --git a/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx index 6809ea009..a0df6affe 100644 --- a/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx @@ -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('', () => { beforeEach(() => { group = buildGroup({ relationship: buildGroupRelationship({ - member: null, + member: false, }), }); }); @@ -98,7 +99,7 @@ describe('', () => { relationship: buildGroupRelationship({ requested: false, member: true, - role: 'owner', + role: GroupRoles.OWNER, }), }); }); @@ -116,7 +117,7 @@ describe('', () => { relationship: buildGroupRelationship({ requested: false, member: true, - role: 'user', + role: GroupRoles.USER, }), }); }); diff --git a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx index 8704b9351..e3171bb81 100644 --- a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx @@ -17,7 +17,7 @@ describe('', () => { requested: false, member: true, blocked_by: true, - role: 'user', + role: GroupRoles.USER, }), }); }); diff --git a/app/soapbox/jest/factory.ts b/app/soapbox/jest/factory.ts index e5bdd796b..ca9f2b8f0 100644 --- a/app/soapbox/jest/factory.ts +++ b/app/soapbox/jest/factory.ts @@ -1,9 +1,13 @@ import { v4 as uuidv4 } from 'uuid'; import { + adSchema, + cardSchema, groupSchema, groupRelationshipSchema, groupTagSchema, + type Ad, + type Card, type Group, type GroupRelationship, type GroupTag, @@ -12,22 +16,34 @@ import { // 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 = {}): Group { +function buildCard(props: Partial = {}): Card { + return cardSchema.parse(Object.assign({ + url: 'https://soapbox.test', + }, props)); +} + +function buildGroup(props: Partial = {}): Group { return groupSchema.parse(Object.assign({ id: uuidv4(), }, props)); } -function buildGroupRelationship(props: Record = {}): GroupRelationship { +function buildGroupRelationship(props: Partial = {}): GroupRelationship { return groupRelationshipSchema.parse(Object.assign({ id: uuidv4(), }, props)); } -function buildGroupTag(props: Record = {}): GroupTag { +function buildGroupTag(props: Partial = {}): GroupTag { return groupTagSchema.parse(Object.assign({ id: uuidv4(), }, props)); } -export { buildGroup, buildGroupRelationship, buildGroupTag }; \ No newline at end of file +function buildAd(props: Partial = {}): Ad { + return adSchema.parse(Object.assign({ + card: buildCard(), + }, props)); +} + +export { buildCard, buildGroup, buildGroupRelationship, buildGroupTag, buildAd }; \ No newline at end of file diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 7f1d76a01..42c075daf 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -26,5 +26,4 @@ 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'; diff --git a/app/soapbox/normalizers/soapbox/ad.ts b/app/soapbox/normalizers/soapbox/ad.ts deleted file mode 100644 index 10ae96d60..000000000 --- a/app/soapbox/normalizers/soapbox/ad.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - Map as ImmutableMap, - Record as ImmutableRecord, - fromJS, -} from 'immutable'; - -import { normalizeCard } from '../card'; - -import type { Ad } from 'soapbox/features/ads/providers'; - -export const AdRecord = ImmutableRecord({ - card: normalizeCard({}), - 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) => { - const map = ImmutableMap(fromJS(ad)); - const card = normalizeCard(map.get('card').toJS()); - const expiresAt = map.get('expires_at') || map.get('expires'); - - return AdRecord(map.merge({ - card, - expires_at: expiresAt, - })); -}; diff --git a/app/soapbox/normalizers/soapbox/soapbox-config.ts b/app/soapbox/normalizers/soapbox/soapbox-config.ts index 3b8b2ea91..f73111611 100644 --- a/app/soapbox/normalizers/soapbox/soapbox-config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox-config.ts @@ -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; const normalizeAds = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => { - const ads = ImmutableList>(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 => { diff --git a/app/soapbox/queries/ads.ts b/app/soapbox/queries/ads.ts index 722f6a38a..c45bf8b33 100644 --- a/app/soapbox/queries/ads.ts +++ b/app/soapbox/queries/ads.ts @@ -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, diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index d8eb7f79c..a6fb6b4c3 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -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), diff --git a/app/soapbox/schemas/group.ts b/app/soapbox/schemas/group.ts index d627ace22..d5ee7f2ee 100644 --- a/app/soapbox/schemas/group.ts +++ b/app/soapbox/schemas/group.ts @@ -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(''), diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts index 23f6067c8..a64ab2942 100644 --- a/app/soapbox/schemas/index.ts +++ b/app/soapbox/schemas/index.ts @@ -1,22 +1,11 @@ -/** - * 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 { 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 { relationshipSchema, type Relationship } from './relationship'; -/** - * Entity Types - */ -export type { Account } from './account'; -export type { Card } from './card'; -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'; \ No newline at end of file diff --git a/app/soapbox/schemas/soapbox/ad.ts b/app/soapbox/schemas/soapbox/ad.ts new file mode 100644 index 000000000..343b519b2 --- /dev/null +++ b/app/soapbox/schemas/soapbox/ad.ts @@ -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().optional().catch(undefined), + reason: z.string().optional().catch(undefined), +}); + +type Ad = z.infer; + +export { adSchema, type Ad }; \ No newline at end of file diff --git a/app/soapbox/schemas/utils.ts b/app/soapbox/schemas/utils.ts index 72f5f49d9..5a62fa0c6 100644 --- a/app/soapbox/schemas/utils.ts +++ b/app/soapbox/schemas/utils.ts @@ -4,7 +4,7 @@ import type { CustomEmoji } from './custom-emoji'; /** Validates individual items in an array, dropping any that aren't valid. */ function filteredArray(schema: T) { - return z.any().array() + return z.any().array().catch([]) .transform((arr) => ( arr.map((item) => { const parsed = schema.safeParse(item); diff --git a/app/soapbox/types/soapbox.ts b/app/soapbox/types/soapbox.ts index 3b8b247a6..e9baff635 100644 --- a/app/soapbox/types/soapbox.ts +++ b/app/soapbox/types/soapbox.ts @@ -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; type PromoPanelItem = ReturnType; type FooterItem = ReturnType; type CryptoAddress = ReturnType; @@ -16,9 +14,12 @@ type SoapboxConfig = ReturnType; export { Me, - Ad, PromoPanelItem, FooterItem, CryptoAddress, SoapboxConfig, }; + +export type { + Ad, +} from 'soapbox/schemas'; \ No newline at end of file diff --git a/app/soapbox/utils/__tests__/ads.test.ts b/app/soapbox/utils/__tests__/ads.test.ts index f96f29936..5ceb9d45b 100644 --- a/app/soapbox/utils/__tests__/ads.test.ts +++ b/app/soapbox/utils/__tests__/ads.test.ts @@ -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); }); diff --git a/app/soapbox/utils/ads.ts b/app/soapbox/utils/ads.ts index 949315191..b59cf430b 100644 --- a/app/soapbox/utils/ads.ts +++ b/app/soapbox/utils/ads.ts @@ -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; From 489145ffb8c456a7fa58162a7fe28454a3042064 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 2 May 2023 18:33:41 -0500 Subject: [PATCH 04/10] Remove normalizeCard() --- .../normalizers/__tests__/card.test.ts | 14 ----------- .../normalizers/__tests__/status.test.ts | 1 - app/soapbox/normalizers/card.ts | 25 ------------------- app/soapbox/normalizers/index.ts | 1 - app/soapbox/schemas/__tests__/card.test.ts | 11 ++++++++ 5 files changed, 11 insertions(+), 41 deletions(-) delete mode 100644 app/soapbox/normalizers/__tests__/card.test.ts delete mode 100644 app/soapbox/normalizers/card.ts create mode 100644 app/soapbox/schemas/__tests__/card.test.ts diff --git a/app/soapbox/normalizers/__tests__/card.test.ts b/app/soapbox/normalizers/__tests__/card.test.ts deleted file mode 100644 index fc8d06221..000000000 --- a/app/soapbox/normalizers/__tests__/card.test.ts +++ /dev/null @@ -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(''); - }); -}); diff --git a/app/soapbox/normalizers/__tests__/status.test.ts b/app/soapbox/normalizers/__tests__/status.test.ts index 5ad29d926..5c66a4b9b 100644 --- a/app/soapbox/normalizers/__tests__/status.test.ts +++ b/app/soapbox/normalizers/__tests__/status.test.ts @@ -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'); }); diff --git a/app/soapbox/normalizers/card.ts b/app/soapbox/normalizers/card.ts deleted file mode 100644 index c29a0c40b..000000000 --- a/app/soapbox/normalizers/card.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { cardSchema, type Card } from 'soapbox/schemas/card'; - -export const normalizeCard = (card: unknown): Card => { - try { - return cardSchema.parse(card); - } catch (_e) { - return { - author_name: '', - author_url: '', - blurhash: null, - description: '', - embed_url: '', - group: null, - height: 0, - html: '', - image: null, - provider_name: '', - provider_url: '', - title: '', - type: 'link', - url: '', - width: 0, - }; - } -}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 42c075daf..ef7dbd7ca 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -4,7 +4,6 @@ export { AdminReportRecord, normalizeAdminReport } from './admin-report'; export { AnnouncementRecord, normalizeAnnouncement } from './announcement'; export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement-reaction'; export { AttachmentRecord, normalizeAttachment } from './attachment'; -export { normalizeCard } from './card'; export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat-message'; export { EmojiRecord, normalizeEmoji } from './emoji'; diff --git a/app/soapbox/schemas/__tests__/card.test.ts b/app/soapbox/schemas/__tests__/card.test.ts new file mode 100644 index 000000000..d66730c21 --- /dev/null +++ b/app/soapbox/schemas/__tests__/card.test.ts @@ -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); + }); +}); From 0016aeacec1c99db39b6466689f758c9fe55f250 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 2 May 2023 18:49:13 -0500 Subject: [PATCH 05/10] Normalize Relationship with zod --- .../actions/__tests__/account-notes.test.ts | 5 +- .../actions/__tests__/accounts.test.ts | 5 +- .../__tests__/subscribe-button.test.tsx | 161 +----------------- app/soapbox/jest/factory.ts | 17 +- app/soapbox/normalizers/index.ts | 1 - app/soapbox/normalizers/relationship.ts | 35 ---- app/soapbox/queries/__tests__/chats.test.ts | 7 +- .../queries/__tests__/relationships.test.ts | 6 +- app/soapbox/reducers/relationships.ts | 17 +- app/soapbox/schemas/relationship.ts | 2 +- app/soapbox/types/entities.ts | 4 +- 11 files changed, 48 insertions(+), 212 deletions(-) delete mode 100644 app/soapbox/normalizers/relationship.ts diff --git a/app/soapbox/actions/__tests__/account-notes.test.ts b/app/soapbox/actions/__tests__/account-notes.test.ts index 8b85eecc5..a00a9d877 100644 --- a/app/soapbox/actions/__tests__/account-notes.test.ts +++ b/app/soapbox/actions/__tests__/account-notes.test.ts @@ -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); }); diff --git a/app/soapbox/actions/__tests__/accounts.test.ts b/app/soapbox/actions/__tests__/accounts.test.ts index d9faa0213..c13f8ef90 100644 --- a/app/soapbox/actions/__tests__/accounts.test.ts +++ b/app/soapbox/actions/__tests__/accounts.test.ts @@ -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); }); diff --git a/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx b/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx index d0ec92f96..5edc9636b 100644 --- a/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx +++ b/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx @@ -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('', () => { 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, null, store); - // expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`); - // }); - // }); - // }); - // }); - }); diff --git a/app/soapbox/jest/factory.ts b/app/soapbox/jest/factory.ts index ca9f2b8f0..07f4bc7d2 100644 --- a/app/soapbox/jest/factory.ts +++ b/app/soapbox/jest/factory.ts @@ -6,11 +6,13 @@ import { 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. @@ -46,4 +48,17 @@ function buildAd(props: Partial = {}): Ad { }, props)); } -export { buildCard, buildGroup, buildGroupRelationship, buildGroupTag, buildAd }; \ No newline at end of file +function buildRelationship(props: Partial = {}): Relationship { + return relationshipSchema.parse(Object.assign({ + id: uuidv4(), + }, props)); +} + +export { + buildCard, + buildGroup, + buildGroupRelationship, + buildGroupTag, + buildAd, + buildRelationship, +}; \ No newline at end of file diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index ef7dbd7ca..d22bce0c9 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -20,7 +20,6 @@ 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'; diff --git a/app/soapbox/normalizers/relationship.ts b/app/soapbox/normalizers/relationship.ts deleted file mode 100644 index f492a00e9..000000000 --- a/app/soapbox/normalizers/relationship.ts +++ /dev/null @@ -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) => { - return RelationshipRecord( - ImmutableMap(fromJS(relationship)), - ); -}; diff --git a/app/soapbox/queries/__tests__/chats.test.ts b/app/soapbox/queries/__tests__/chats.test.ts index 65bb6294d..981250456 100644 --- a/app/soapbox/queries/__tests__/chats.test.ts +++ b/app/soapbox/queries/__tests__/chats.test.ts @@ -3,8 +3,9 @@ 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 { normalizeChatMessage } from 'soapbox/normalizers'; import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction'; import { Store } from 'soapbox/store'; import { ChatMessage } from 'soapbox/types/entities'; @@ -120,7 +121,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 +240,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 })]); }); }); diff --git a/app/soapbox/queries/__tests__/relationships.test.ts b/app/soapbox/queries/__tests__/relationships.test.ts index 6466da7ff..02db36166 100644 --- a/app/soapbox/queries/__tests__/relationships.test.ts +++ b/app/soapbox/queries/__tests__/relationships.test.ts @@ -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 }))); }); }); diff --git a/app/soapbox/reducers/relationships.ts b/app/soapbox/reducers/relationships.ts index 2eb035ec4..40d062f78 100644 --- a/app/soapbox/reducers/relationships.ts +++ b/app/soapbox/reducers/relationships.ts @@ -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; type State = ImmutableMap; type APIEntities = Array; 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(), action: AnyAction) { diff --git a/app/soapbox/schemas/relationship.ts b/app/soapbox/schemas/relationship.ts index 7d1e109c8..003cf747a 100644 --- a/app/soapbox/schemas/relationship.ts +++ b/app/soapbox/schemas/relationship.ts @@ -19,4 +19,4 @@ const relationshipSchema = z.object({ type Relationship = z.infer; -export { relationshipSchema, Relationship }; \ No newline at end of file +export { relationshipSchema, type Relationship }; \ No newline at end of file diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index ebbdd197d..27082f76f 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -21,7 +21,6 @@ import { NotificationRecord, PollRecord, PollOptionRecord, - RelationshipRecord, StatusEditRecord, StatusRecord, TagRecord, @@ -52,7 +51,6 @@ type Mention = ReturnType; type Notification = ReturnType; type Poll = ReturnType; type PollOption = ReturnType; -type Relationship = ReturnType; type StatusEdit = ReturnType; type Tag = ReturnType; @@ -96,7 +94,6 @@ export { Notification, Poll, PollOption, - Relationship, Status, StatusEdit, Tag, @@ -111,4 +108,5 @@ export type { Group, GroupMember, GroupRelationship, + Relationship, } from 'soapbox/schemas'; \ No newline at end of file From e3fcff55f9194e5f9e491644d68a12ca5e2f344c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 2 May 2023 19:11:17 -0500 Subject: [PATCH 06/10] Convert EmojiReaction to zod --- .../__tests__/chat-message-reaction.test.tsx | 6 ++---- .../features/chats/components/chat-message.tsx | 2 +- app/soapbox/normalizers/chat-message.ts | 15 +++++---------- app/soapbox/normalizers/emoji-reaction.ts | 14 -------------- app/soapbox/normalizers/index.ts | 1 - app/soapbox/queries/__tests__/chats.test.ts | 7 +++---- app/soapbox/schemas/emoji-reaction.ts | 15 +++++++++++++++ app/soapbox/schemas/index.ts | 1 + app/soapbox/types/entities.ts | 4 +--- app/soapbox/utils/features.ts | 2 +- 10 files changed, 29 insertions(+), 38 deletions(-) delete mode 100644 app/soapbox/normalizers/emoji-reaction.ts create mode 100644 app/soapbox/schemas/emoji-reaction.ts diff --git a/app/soapbox/features/chats/components/__tests__/chat-message-reaction.test.tsx b/app/soapbox/features/chats/components/__tests__/chat-message-reaction.test.tsx index 45fac5383..6ab22d4d5 100644 --- a/app/soapbox/features/chats/components/__tests__/chat-message-reaction.test.tsx +++ b/app/soapbox/features/chats/components/__tests__/chat-message-reaction.test.tsx @@ -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('', () => { render( { - {(chatMessage.emoji_reactions?.size) ? ( + {(chatMessage.emoji_reactions?.length) ? (
(), expiration: null as number | null, - emoji_reactions: null as ImmutableList | null, + emoji_reactions: null as readonly EmojiReaction[] | null, id: '', unread: false, deleting: false, @@ -41,13 +41,8 @@ const normalizeMedia = (status: ImmutableMap) => { }; const normalizeChatMessageEmojiReaction = (chatMessage: ImmutableMap) => { - const emojiReactions = chatMessage.get('emoji_reactions'); - - if (emojiReactions) { - return chatMessage.set('emoji_reactions', ImmutableList(emojiReactions.map(normalizeEmojiReaction))); - } else { - return chatMessage; - } + const emojiReactions = chatMessage.get('emoji_reactions') || ImmutableList(); + return chatMessage.set('emoji_reactions', filteredArray(emojiReactionSchema).parse(emojiReactions.toJS())); }; /** Rewrite `

` to empty string. */ diff --git a/app/soapbox/normalizers/emoji-reaction.ts b/app/soapbox/normalizers/emoji-reaction.ts deleted file mode 100644 index 88dcfd1e4..000000000 --- a/app/soapbox/normalizers/emoji-reaction.ts +++ /dev/null @@ -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) => { - return EmojiReactionRecord( - ImmutableMap(fromJS(emojiReaction)), - ); -}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index d22bce0c9..e7100fa9d 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -7,7 +7,6 @@ export { AttachmentRecord, normalizeAttachment } from './attachment'; 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'; diff --git a/app/soapbox/queries/__tests__/chats.test.ts b/app/soapbox/queries/__tests__/chats.test.ts index 981250456..3bcd1b9a7 100644 --- a/app/soapbox/queries/__tests__/chats.test.ts +++ b/app/soapbox/queries/__tests__/chats.test.ts @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { Map as ImmutableMap } from 'immutable'; import sumBy from 'lodash/sumBy'; import { useEffect } from 'react'; @@ -6,7 +6,6 @@ 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 } from 'soapbox/normalizers'; -import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction'; import { Store } from 'soapbox/store'; import { ChatMessage } from 'soapbox/types/entities'; import { flattenPages } from 'soapbox/utils/queries'; @@ -426,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, - })])); + }]); }); }); }); diff --git a/app/soapbox/schemas/emoji-reaction.ts b/app/soapbox/schemas/emoji-reaction.ts new file mode 100644 index 000000000..55c1762a0 --- /dev/null +++ b/app/soapbox/schemas/emoji-reaction.ts @@ -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; + +export { emojiReactionSchema, EmojiReaction }; \ No newline at end of file diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts index a64ab2942..29b1c2edd 100644 --- a/app/soapbox/schemas/index.ts +++ b/app/soapbox/schemas/index.ts @@ -1,6 +1,7 @@ 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'; diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index 27082f76f..e99aa3acf 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -8,7 +8,6 @@ import { ChatRecord, ChatMessageRecord, EmojiRecord, - EmojiReactionRecord, FieldRecord, FilterRecord, FilterKeywordRecord, @@ -38,7 +37,6 @@ type Attachment = ReturnType; type Chat = ReturnType; type ChatMessage = ReturnType; type Emoji = ReturnType; -type EmojiReaction = ReturnType; type Field = ReturnType; type Filter = ReturnType; type FilterKeyword = ReturnType; @@ -81,7 +79,6 @@ export { Chat, ChatMessage, Emoji, - EmojiReaction, Field, Filter, FilterKeyword, @@ -105,6 +102,7 @@ export { export type { Card, + EmojiReaction, Group, GroupMember, GroupRelationship, diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 9c5a68bda..dd818ac2b 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -254,7 +254,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. From fb0f20cb6412c819f3fc14a2361e2705fc510ab6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 2 May 2023 19:22:59 -0500 Subject: [PATCH 07/10] cardSchema: drop card.pleroma from transformed type --- app/soapbox/schemas/card.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/soapbox/schemas/card.ts b/app/soapbox/schemas/card.ts index c3826fa1d..d35c9f109 100644 --- a/app/soapbox/schemas/card.ts +++ b/app/soapbox/schemas/card.ts @@ -34,23 +34,23 @@ const cardSchema = z.object({ type: z.enum(['link', 'photo', 'video', 'rich']).catch('link'), url: z.string().url(), width: z.number().catch(0), -}).transform((card) => { +}).transform(({ pleroma, ...card }) => { if (!card.provider_name) { card.provider_name = decodeIDNA(new URL(card.url).hostname); } - if (card.pleroma?.opengraph) { + if (pleroma?.opengraph) { if (!card.width && !card.height) { - card.width = card.pleroma.opengraph.width; - card.height = card.pleroma.opengraph.height; + card.width = pleroma.opengraph.width; + card.height = pleroma.opengraph.height; } if (!card.html) { - card.html = card.pleroma.opengraph.html; + card.html = pleroma.opengraph.html; } if (!card.image) { - card.image = card.pleroma.opengraph.thumbnail_url; + card.image = pleroma.opengraph.thumbnail_url; } } From 211fdd52f5176ac22af70bf60c975bbdab15d929 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 May 2023 08:01:02 -0500 Subject: [PATCH 08/10] Fix normalizeChatMessageEmojiReaction --- app/soapbox/normalizers/chat-message.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/normalizers/chat-message.ts b/app/soapbox/normalizers/chat-message.ts index 9f63c4daf..5ec03bb59 100644 --- a/app/soapbox/normalizers/chat-message.ts +++ b/app/soapbox/normalizers/chat-message.ts @@ -41,7 +41,7 @@ const normalizeMedia = (status: ImmutableMap) => { }; const normalizeChatMessageEmojiReaction = (chatMessage: ImmutableMap) => { - const emojiReactions = chatMessage.get('emoji_reactions') || ImmutableList(); + const emojiReactions = ImmutableList(chatMessage.get('emoji_reactions') || []); return chatMessage.set('emoji_reactions', filteredArray(emojiReactionSchema).parse(emojiReactions.toJS())); }; From d4ed442a7eed135bdd8eaeb686ec05b5997c0025 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 May 2023 12:52:36 -0500 Subject: [PATCH 09/10] Normalize poll with zod --- .../polls/__tests__/poll-footer.test.tsx | 44 ++++---- app/soapbox/components/polls/poll-footer.tsx | 8 +- app/soapbox/components/polls/poll-option.tsx | 5 +- .../normalizers/__tests__/poll.test.ts | 47 -------- .../normalizers/__tests__/status.test.ts | 18 ++-- app/soapbox/normalizers/index.ts | 1 - app/soapbox/normalizers/poll.ts | 102 ------------------ app/soapbox/normalizers/status-edit.ts | 9 +- app/soapbox/normalizers/status.ts | 10 +- app/soapbox/reducers/__tests__/polls.test.ts | 7 +- app/soapbox/reducers/statuses.ts | 4 +- app/soapbox/schemas/__tests__/poll.test.ts | 44 ++++++++ app/soapbox/schemas/account.ts | 4 +- app/soapbox/schemas/index.ts | 1 + app/soapbox/schemas/poll.ts | 50 +++++++++ app/soapbox/schemas/soapbox/ad.ts | 2 +- app/soapbox/types/entities.ts | 8 +- 17 files changed, 160 insertions(+), 204 deletions(-) delete mode 100644 app/soapbox/normalizers/__tests__/poll.test.ts delete mode 100644 app/soapbox/normalizers/poll.ts create mode 100644 app/soapbox/schemas/__tests__/poll.test.ts create mode 100644 app/soapbox/schemas/poll.ts 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 From f48edfba456652318451cf46e7fd7e515458d55f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 May 2023 13:40:30 -0500 Subject: [PATCH 10/10] Add tagSchema --- app/soapbox/schemas/index.ts | 1 + app/soapbox/schemas/tag.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 app/soapbox/schemas/tag.ts diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts index 655bffc7f..25f5f3d45 100644 --- a/app/soapbox/schemas/index.ts +++ b/app/soapbox/schemas/index.ts @@ -8,6 +8,7 @@ export { groupRelationshipSchema, type GroupRelationship } from './group-relatio 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'; // Soapbox export { adSchema, type Ad } from './soapbox/ad'; \ No newline at end of file diff --git a/app/soapbox/schemas/tag.ts b/app/soapbox/schemas/tag.ts new file mode 100644 index 000000000..5f74a31c7 --- /dev/null +++ b/app/soapbox/schemas/tag.ts @@ -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; + +export { tagSchema, type Tag }; \ No newline at end of file