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;