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;