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"