From 37f5b35aab8d93ee9c3c5bcfefb63ebe75c5027b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 10 Mar 2023 11:35:18 -0600 Subject: [PATCH 01/10] Add zod --- package.json | 3 ++- yarn.lock | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1fcbf2a93..370890968 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 d81f71d21..3b59ac465 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18306,6 +18306,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" From 6be8d4d46ef510e362e08ffa7e3836b83b454a8e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 10 Mar 2023 12:42:49 -0600 Subject: [PATCH 02/10] Add groups schemas with zod --- app/soapbox/schemas/custom-emoji.ts | 17 ++++++++ app/soapbox/schemas/group-relationship.ts | 12 ++++++ app/soapbox/schemas/group.ts | 49 +++++++++++++++++++++++ app/soapbox/schemas/utils.ts | 21 ++++++++++ 4 files changed, 99 insertions(+) create mode 100644 app/soapbox/schemas/custom-emoji.ts create mode 100644 app/soapbox/schemas/group-relationship.ts create mode 100644 app/soapbox/schemas/group.ts create mode 100644 app/soapbox/schemas/utils.ts 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..74246adbc --- /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), + 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().optional().catch(undefined), + note: z.string().catch('').refine(note => note === '

' ? '' : note), + relationship: groupRelationshipSchema.optional().catch(undefined), // Dummy field to be overwritten later + statuses_visibility: z.string().catch('public'), + uri: z.string().catch(''), + url: z.string().catch(''), +}).refine(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/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 From 5e8c92ed4de4f00f900fe4cd23668ac467ba815f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 10 Mar 2023 13:13:54 -0600 Subject: [PATCH 03/10] groupSchema: refine --> transform, fix type of members_count --- app/soapbox/schemas/group.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/schemas/group.ts b/app/soapbox/schemas/group.ts index 74246adbc..964831c1d 100644 --- a/app/soapbox/schemas/group.ts +++ b/app/soapbox/schemas/group.ts @@ -24,13 +24,13 @@ const groupSchema = z.object({ id: z.string().catch(''), locked: z.boolean().catch(false), membership_required: z.boolean().catch(false), - members_count: z.number().optional().catch(undefined), + members_count: z.number().catch(0), note: z.string().catch('').refine(note => note === '

' ? '' : note), - relationship: groupRelationshipSchema.optional().catch(undefined), // Dummy field to be overwritten later + 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(''), -}).refine(group => { +}).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 From 3d2331d20b4f7ec07872dfc3d9c52b1d50285bc3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 10 Mar 2023 13:36:00 -0600 Subject: [PATCH 04/10] Make useGroups hooks use zod parser, update group types --- .../__tests__/group-action-button.test.tsx | 8 +++--- app/soapbox/hooks/useGroups.ts | 26 ++++++++++++------- app/soapbox/normalizers/group.ts | 7 ++--- app/soapbox/normalizers/index.ts | 2 +- app/soapbox/schemas/index.ts | 3 +++ app/soapbox/types/entities.ts | 11 ++++---- 6 files changed, 34 insertions(+), 23 deletions(-) create mode 100644 app/soapbox/schemas/index.ts 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/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/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/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 From 6a2c64ae45ca63962ecc5a7bab474b11724162e2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 10 Mar 2023 13:41:47 -0600 Subject: [PATCH 05/10] groupSchema: catch emojis --- app/soapbox/schemas/group.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/schemas/group.ts b/app/soapbox/schemas/group.ts index 964831c1d..f735f4e87 100644 --- a/app/soapbox/schemas/group.ts +++ b/app/soapbox/schemas/group.ts @@ -17,7 +17,7 @@ const groupSchema = z.object({ created_at: z.string().datetime().catch(new Date().toUTCString()), display_name: z.string().catch(''), domain: z.string().catch(''), - emojis: filteredArray(customEmojiSchema), + emojis: filteredArray(customEmojiSchema).catch([]), group_visibility: z.string().catch(''), // TruthSocial header: z.string().catch(headerMissing), header_static: z.string().catch(''), From 1af67c3a256e2b86d3d30a866c6e57497057a795 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 10 Mar 2023 13:46:20 -0600 Subject: [PATCH 06/10] members_count will never be null --- .../__tests__/group-member-count.test.tsx | 14 -------------- .../group/components/group-member-count.tsx | 4 ---- 2 files changed, 18 deletions(-) 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)} From 487604b15aaa9726f37e7050b3483e4f686b9800 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 13 Mar 2023 11:54:22 -0500 Subject: [PATCH 07/10] Fix useGroupsPath test --- app/soapbox/hooks/__tests__/useGroupsPath.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/soapbox/hooks/__tests__/useGroupsPath.test.ts b/app/soapbox/hooks/__tests__/useGroupsPath.test.ts index c3ec1e169..167e477b3 100644 --- a/app/soapbox/hooks/__tests__/useGroupsPath.test.ts +++ b/app/soapbox/hooks/__tests__/useGroupsPath.test.ts @@ -68,6 +68,19 @@ describe('useGroupsPath()', () => { }); test('should default to the "My Groups" page', async () => { + const store = { + entities: { + Groups: { + store: { + '1': normalizeGroup({}), + }, + lists: { + '': new Set(['1']), + }, + }, + }, + }; + const { result } = renderHook(useGroupsPath, undefined, store); await waitFor(() => { From a0c67c9b6f8ded3535cc1c212d9f1ccbe066f9f6 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 13 Mar 2023 13:00:55 -0400 Subject: [PATCH 08/10] Fix Zod parsing error --- app/soapbox/schemas/group.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/schemas/group.ts b/app/soapbox/schemas/group.ts index f735f4e87..f6b1a12a1 100644 --- a/app/soapbox/schemas/group.ts +++ b/app/soapbox/schemas/group.ts @@ -25,7 +25,7 @@ const groupSchema = z.object({ locked: z.boolean().catch(false), membership_required: z.boolean().catch(false), members_count: z.number().catch(0), - note: z.string().catch('').refine(note => note === '

' ? '' : note), + note: z.string().refine(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(''), From a9b79f72b476370dda742d668c1d1532394e7a14 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 13 Mar 2023 13:02:41 -0400 Subject: [PATCH 09/10] Revert "Fix useGroupsPath test" This reverts commit 487604b15aaa9726f37e7050b3483e4f686b9800. --- app/soapbox/hooks/__tests__/useGroupsPath.test.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/app/soapbox/hooks/__tests__/useGroupsPath.test.ts b/app/soapbox/hooks/__tests__/useGroupsPath.test.ts index 167e477b3..c3ec1e169 100644 --- a/app/soapbox/hooks/__tests__/useGroupsPath.test.ts +++ b/app/soapbox/hooks/__tests__/useGroupsPath.test.ts @@ -68,19 +68,6 @@ describe('useGroupsPath()', () => { }); test('should default to the "My Groups" page', async () => { - const store = { - entities: { - Groups: { - store: { - '1': normalizeGroup({}), - }, - lists: { - '': new Set(['1']), - }, - }, - }, - }; - const { result } = renderHook(useGroupsPath, undefined, store); await waitFor(() => { From 607e6b1808df74e49eebcad1e8d15e2935149549 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 13 Mar 2023 12:05:56 -0500 Subject: [PATCH 10/10] groupsSchema: refine --> transform --- app/soapbox/schemas/group.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/schemas/group.ts b/app/soapbox/schemas/group.ts index f6b1a12a1..6a8f03c8f 100644 --- a/app/soapbox/schemas/group.ts +++ b/app/soapbox/schemas/group.ts @@ -25,7 +25,7 @@ const groupSchema = z.object({ locked: z.boolean().catch(false), membership_required: z.boolean().catch(false), members_count: z.number().catch(0), - note: z.string().refine(note => note === '

' ? '' : note).catch(''), + 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(''),