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 eb2cf670b..e622c7247 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
@@ -18,7 +18,7 @@ describe('', () => {
describe('with a private group', () => {
beforeEach(() => {
- group = group.set('locked', true);
+ group = { ...group, locked: true };
});
it('should render the Request Access button', () => {
@@ -30,7 +30,7 @@ describe('', () => {
describe('with a public group', () => {
beforeEach(() => {
- group = group.set('locked', false);
+ group = { ...group, locked: false };
});
it('should render the Join Group button', () => {
@@ -52,7 +52,7 @@ describe('', () => {
describe('with a private group', () => {
beforeEach(() => {
- group = group.set('locked', true);
+ group = { ...group, locked: true };
});
it('should render the Request Access button', () => {
@@ -64,7 +64,7 @@ describe('', () => {
describe('with a public group', () => {
beforeEach(() => {
- group = group.set('locked', false);
+ group = { ...group, locked: false };
});
it('should render the Join Group button', () => {
diff --git a/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx b/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx
index 86e9baac8..9a26c2729 100644
--- a/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx
+++ b/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx
@@ -9,20 +9,6 @@ import GroupMemberCount from '../group-member-count';
let group: Group;
describe('', () => {
- describe('without support for "members_count"', () => {
- beforeEach(() => {
- group = normalizeGroup({
- members_count: undefined,
- });
- });
-
- it('should return null', () => {
- render();
-
- expect(screen.queryAllByTestId('group-member-count')).toHaveLength(0);
- });
- });
-
describe('with support for "members_count"', () => {
describe('with 1 member', () => {
beforeEach(() => {
diff --git a/app/soapbox/features/group/components/group-member-count.tsx b/app/soapbox/features/group/components/group-member-count.tsx
index e4dd33e54..6dc936181 100644
--- a/app/soapbox/features/group/components/group-member-count.tsx
+++ b/app/soapbox/features/group/components/group-member-count.tsx
@@ -10,10 +10,6 @@ interface IGroupMemberCount {
}
const GroupMemberCount = ({ group }: IGroupMemberCount) => {
- if (typeof group.members_count === 'undefined') {
- return null;
- }
-
return (
{shortNumberFormat(group.members_count)}
diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts
index 1c48e1e38..65437675b 100644
--- a/app/soapbox/hooks/useGroups.ts
+++ b/app/soapbox/hooks/useGroups.ts
@@ -1,13 +1,12 @@
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
-import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
-
-import type { Group, GroupRelationship } from 'soapbox/types/entities';
+import { groupSchema, Group } from 'soapbox/schemas/group';
+import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
function useGroups() {
const { entities, ...result } = useEntities(['Group', ''], '/api/v1/groups', { parser: parseGroup });
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
- const groups = entities.map((group) => group.set('relationship', relationships[group.id] || null));
+ const groups = entities.map((group) => ({ ...group, relationship: relationships[group.id] || null }));
return {
...result,
@@ -21,7 +20,7 @@ function useGroup(groupId: string, refetch = true) {
return {
...result,
- group: group?.set('relationship', relationship || null),
+ group: group ? { ...group, relationship: relationship || null } : undefined,
};
}
@@ -45,9 +44,18 @@ function useGroupRelationships(groupIds: string[]) {
};
}
-// HACK: normalizers currently don't have the desired API.
-// TODO: rewrite normalizers as Zod parsers.
-const parseGroup = (entity: unknown) => entity ? normalizeGroup(entity as Record) : undefined;
-const parseGroupRelationship = (entity: unknown) => entity ? normalizeGroupRelationship(entity as Record) : undefined;
+const parseGroup = (entity: unknown) => {
+ const result = groupSchema.safeParse(entity);
+ if (result.success) {
+ return result.data;
+ }
+};
+
+const parseGroupRelationship = (entity: unknown) => {
+ const result = groupRelationshipSchema.safeParse(entity);
+ if (result.success) {
+ return result.data;
+ }
+};
export { useGroup, useGroups };
\ No newline at end of file
diff --git a/app/soapbox/normalizers/group.ts b/app/soapbox/normalizers/group.ts
index e4bf1df6b..e50cc9af3 100644
--- a/app/soapbox/normalizers/group.ts
+++ b/app/soapbox/normalizers/group.ts
@@ -23,13 +23,14 @@ export const GroupRecord = ImmutableRecord({
created_at: '',
display_name: '',
domain: '',
- emojis: ImmutableList(),
+ emojis: [] as Emoji[],
+ group_visibility: '',
header: '',
header_static: '',
id: '',
locked: false,
membership_required: false,
- members_count: undefined as number | undefined,
+ members_count: 0,
note: '',
statuses_visibility: 'public',
uri: '',
@@ -69,7 +70,7 @@ const normalizeHeader = (group: ImmutableMap) => {
/** Normalize emojis */
const normalizeEmojis = (entity: ImmutableMap) => {
const emojis = entity.get('emojis', ImmutableList()).map(normalizeEmoji);
- return entity.set('emojis', emojis);
+ return entity.set('emojis', emojis.toArray());
};
/** Set display name from username, if applicable */
diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts
index 66daaae27..004049988 100644
--- a/app/soapbox/normalizers/index.ts
+++ b/app/soapbox/normalizers/index.ts
@@ -12,7 +12,7 @@ export { EmojiReactionRecord } from './emoji-reaction';
export { FilterRecord, normalizeFilter } from './filter';
export { FilterKeywordRecord, normalizeFilterKeyword } from './filter-keyword';
export { FilterStatusRecord, normalizeFilterStatus } from './filter-status';
-export { GroupRecord, normalizeGroup } from './group';
+export { normalizeGroup } from './group';
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
export { HistoryRecord, normalizeHistory } from './history';
export { InstanceRecord, normalizeInstance } from './instance';
diff --git a/app/soapbox/schemas/custom-emoji.ts b/app/soapbox/schemas/custom-emoji.ts
new file mode 100644
index 000000000..68c49c587
--- /dev/null
+++ b/app/soapbox/schemas/custom-emoji.ts
@@ -0,0 +1,17 @@
+import z from 'zod';
+
+/**
+ * Represents a custom emoji.
+ * https://docs.joinmastodon.org/entities/CustomEmoji/
+ */
+const customEmojiSchema = z.object({
+ category: z.string().catch(''),
+ shortcode: z.string(),
+ static_url: z.string().catch(''),
+ url: z.string(),
+ visible_in_picker: z.boolean().catch(true),
+});
+
+type CustomEmoji = z.infer;
+
+export { customEmojiSchema, CustomEmoji };
diff --git a/app/soapbox/schemas/group-relationship.ts b/app/soapbox/schemas/group-relationship.ts
new file mode 100644
index 000000000..8339466ab
--- /dev/null
+++ b/app/soapbox/schemas/group-relationship.ts
@@ -0,0 +1,12 @@
+import z from 'zod';
+
+const groupRelationshipSchema = z.object({
+ id: z.string(),
+ member: z.boolean().catch(false),
+ requested: z.boolean().catch(false),
+ role: z.string().nullish().catch(null),
+});
+
+type GroupRelationship = z.infer;
+
+export { groupRelationshipSchema, GroupRelationship };
\ No newline at end of file
diff --git a/app/soapbox/schemas/group.ts b/app/soapbox/schemas/group.ts
new file mode 100644
index 000000000..6a8f03c8f
--- /dev/null
+++ b/app/soapbox/schemas/group.ts
@@ -0,0 +1,49 @@
+import escapeTextContentForBrowser from 'escape-html';
+import z from 'zod';
+
+import emojify from 'soapbox/features/emoji';
+import { unescapeHTML } from 'soapbox/utils/html';
+
+import { customEmojiSchema } from './custom-emoji';
+import { groupRelationshipSchema } from './group-relationship';
+import { filteredArray, makeCustomEmojiMap } from './utils';
+
+const avatarMissing = require('assets/images/avatar-missing.png');
+const headerMissing = require('assets/images/header-missing.png');
+
+const groupSchema = z.object({
+ avatar: z.string().catch(avatarMissing),
+ avatar_static: z.string().catch(''),
+ created_at: z.string().datetime().catch(new Date().toUTCString()),
+ display_name: z.string().catch(''),
+ domain: z.string().catch(''),
+ emojis: filteredArray(customEmojiSchema).catch([]),
+ group_visibility: z.string().catch(''), // TruthSocial
+ header: z.string().catch(headerMissing),
+ header_static: z.string().catch(''),
+ id: z.string().catch(''),
+ locked: z.boolean().catch(false),
+ membership_required: z.boolean().catch(false),
+ members_count: z.number().catch(0),
+ note: z.string().transform(note => note === '' ? '' : note).catch(''),
+ relationship: groupRelationshipSchema.nullable().catch(null), // Dummy field to be overwritten later
+ statuses_visibility: z.string().catch('public'),
+ uri: z.string().catch(''),
+ url: z.string().catch(''),
+}).transform(group => {
+ group.avatar_static = group.avatar_static || group.avatar;
+ group.header_static = group.header_static || group.header;
+ group.locked = group.locked || group.group_visibility === 'members_only'; // TruthSocial
+
+ const customEmojiMap = makeCustomEmojiMap(group.emojis);
+ return {
+ ...group,
+ display_name_html: emojify(escapeTextContentForBrowser(group.display_name), customEmojiMap),
+ note_emojified: emojify(group.note, customEmojiMap),
+ note_plain: unescapeHTML(group.note),
+ };
+});
+
+type Group = z.infer;
+
+export { groupSchema, Group };
\ No newline at end of file
diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts
new file mode 100644
index 000000000..e05df212a
--- /dev/null
+++ b/app/soapbox/schemas/index.ts
@@ -0,0 +1,3 @@
+export { customEmojiSchema, CustomEmoji } from './custom-emoji';
+export { groupSchema, Group } from './group';
+export { groupRelationshipSchema, GroupRelationship } from './group-relationship';
\ No newline at end of file
diff --git a/app/soapbox/schemas/utils.ts b/app/soapbox/schemas/utils.ts
new file mode 100644
index 000000000..d0bc4cc8f
--- /dev/null
+++ b/app/soapbox/schemas/utils.ts
@@ -0,0 +1,21 @@
+import z from 'zod';
+
+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().transform((arr) => (
+ arr.map((item) => schema.safeParse(item).success ? item as z.infer : undefined)
+ .filter((item): item is z.infer => Boolean(item))
+ ));
+}
+
+/** Map a list of CustomEmoji to their shortcodes. */
+function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
+ return customEmojis.reduce>((result, emoji) => {
+ result[`:${emoji.shortcode}:`] = emoji;
+ return result;
+ }, {});
+}
+
+export { filteredArray, makeCustomEmojiMap };
\ No newline at end of file
diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts
index bf717e805..61691a54a 100644
--- a/app/soapbox/types/entities.ts
+++ b/app/soapbox/types/entities.ts
@@ -14,8 +14,6 @@ import {
FilterRecord,
FilterKeywordRecord,
FilterStatusRecord,
- GroupRecord,
- GroupRelationshipRecord,
HistoryRecord,
InstanceRecord,
ListRecord,
@@ -48,8 +46,6 @@ type Field = ReturnType;
type Filter = ReturnType;
type FilterKeyword = ReturnType;
type FilterStatus = ReturnType;
-type Group = ReturnType;
-type GroupRelationship = ReturnType;
type History = ReturnType;
type Instance = ReturnType;
type List = ReturnType;
@@ -95,8 +91,6 @@ export {
Filter,
FilterKeyword,
FilterStatus,
- Group,
- GroupRelationship,
History,
Instance,
List,
@@ -114,3 +108,8 @@ export {
APIEntity,
EmbeddedEntity,
};
+
+export type {
+ Group,
+ GroupRelationship,
+} from 'soapbox/schemas';
\ No newline at end of file
diff --git a/package.json b/package.json
index 00599a284..536a351a4 100644
--- a/package.json
+++ b/package.json
@@ -194,7 +194,8 @@
"webpack-cli": "^5.0.0",
"webpack-deadcode-plugin": "^0.1.16",
"webpack-merge": "^5.8.0",
- "wicg-inert": "^3.1.1"
+ "wicg-inert": "^3.1.1",
+ "zod": "^3.21.4"
},
"devDependencies": {
"@babel/eslint-parser": "^7.19.1",
diff --git a/yarn.lock b/yarn.lock
index d0088d857..fb11d9629 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18294,6 +18294,11 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
+zod@^3.21.4:
+ version "3.21.4"
+ resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
+ integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==
+
zwitch@^1.0.0:
version "1.0.5"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"