Merge branch 'zod-notification' into 'develop'

zod: Notification, Attachment, ChatMessage, Status

See merge request soapbox-pub/soapbox!2500
This commit is contained in:
Alex Gleason 2023-05-08 15:49:08 +00:00
commit c5c2378542
14 changed files with 319 additions and 15 deletions

View File

@ -1,7 +1,10 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { statusSchema } from 'soapbox/schemas/status';
import { normalizeStatus } from 'soapbox/normalizers';
import { toSchema } from 'soapbox/utils/normalizers';
const statusSchema = toSchema(normalizeStatus);
function useGroupMedia(groupId: string) {
const api = useApi();

View File

@ -5,7 +5,7 @@ import emojify from 'soapbox/features/emoji';
import { customEmojiSchema } from './custom-emoji';
import { relationshipSchema } from './relationship';
import { filteredArray, makeCustomEmojiMap } from './utils';
import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils';
const avatarMissing = require('assets/images/avatar-missing.png');
const headerMissing = require('assets/images/header-missing.png');
@ -39,7 +39,7 @@ const accountSchema = z.object({
z.string(),
z.null(),
]).catch(null),
note: z.string().catch(''),
note: contentSchema,
pleroma: z.any(), // TODO
source: z.any(), // TODO
statuses_count: z.number().catch(0),
@ -121,4 +121,4 @@ const accountSchema = z.object({
type Account = z.infer<typeof accountSchema>;
export { accountSchema, Account };
export { accountSchema, type Account };

View File

@ -0,0 +1,89 @@
import { isBlurhashValid } from 'blurhash';
import { z } from 'zod';
const blurhashSchema = z.string().superRefine((value, ctx) => {
const r = isBlurhashValid(value);
if (!r.result) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: r.errorReason,
});
}
});
const baseAttachmentSchema = z.object({
blurhash: blurhashSchema.nullable().catch(null),
description: z.string().catch(''),
external_video_id: z.string().optional().catch(undefined), // TruthSocial
id: z.string(),
pleroma: z.object({
mime_type: z.string().regex(/^\w+\/[-+.\w]+$/),
}).optional().catch(undefined),
preview_url: z.string().url().catch(''),
remote_url: z.string().url().nullable().catch(null),
type: z.string(),
url: z.string().url(),
});
const imageMetaSchema = z.object({
width: z.number(),
height: z.number(),
aspect: z.number().optional().catch(undefined),
}).transform((meta) => ({
...meta,
aspect: typeof meta.aspect === 'number' ? meta.aspect : meta.width / meta.height,
}));
const imageAttachmentSchema = baseAttachmentSchema.extend({
type: z.literal('image'),
meta: z.object({
original: imageMetaSchema.optional().catch(undefined),
}).catch({}),
});
const videoAttachmentSchema = baseAttachmentSchema.extend({
type: z.literal('video'),
meta: z.object({
duration: z.number().optional().catch(undefined),
original: imageMetaSchema.optional().catch(undefined),
}).catch({}),
});
const gifvAttachmentSchema = baseAttachmentSchema.extend({
type: z.literal('gifv'),
meta: z.object({
duration: z.number().optional().catch(undefined),
original: imageMetaSchema.optional().catch(undefined),
}).catch({}),
});
const audioAttachmentSchema = baseAttachmentSchema.extend({
type: z.literal('audio'),
meta: z.object({
duration: z.number().optional().catch(undefined),
}).catch({}),
});
const unknownAttachmentSchema = baseAttachmentSchema.extend({
type: z.literal('unknown'),
});
/** https://docs.joinmastodon.org/entities/attachment */
const attachmentSchema = z.discriminatedUnion('type', [
imageAttachmentSchema,
videoAttachmentSchema,
gifvAttachmentSchema,
audioAttachmentSchema,
unknownAttachmentSchema,
]).transform((attachment) => {
if (!attachment.preview_url) {
attachment.preview_url = attachment.url;
}
return attachment;
});
type Attachment = z.infer<typeof attachmentSchema>;
export { attachmentSchema, type Attachment };

View File

@ -0,0 +1,26 @@
import { z } from 'zod';
import { attachmentSchema } from './attachment';
import { cardSchema } from './card';
import { customEmojiSchema } from './custom-emoji';
import { contentSchema, emojiSchema, filteredArray } from './utils';
const chatMessageSchema = z.object({
account_id: z.string(),
media_attachments: filteredArray(attachmentSchema),
card: cardSchema.nullable().catch(null),
chat_id: z.string(),
content: contentSchema,
created_at: z.string().datetime().catch(new Date().toUTCString()),
emojis: filteredArray(customEmojiSchema),
expiration: z.number().optional().catch(undefined),
emoji_reactions: z.array(emojiSchema).min(1).nullable().catch(null),
id: z.string(),
unread: z.coerce.boolean(),
deleting: z.coerce.boolean(),
pending: z.coerce.boolean(),
});
type ChatMessage = z.infer<typeof chatMessageSchema>;
export { chatMessageSchema, type ChatMessage };

View File

@ -14,4 +14,4 @@ const customEmojiSchema = z.object({
type CustomEmoji = z.infer<typeof customEmojiSchema>;
export { customEmojiSchema, CustomEmoji };
export { customEmojiSchema, type CustomEmoji };

View File

@ -1,7 +1,6 @@
import { z } from 'zod';
/** Validates the string as an emoji. */
const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v));
import { emojiSchema } from './utils';
/** Pleroma emoji reaction. */
const emojiReactionSchema = z.object({
@ -12,4 +11,4 @@ const emojiReactionSchema = z.object({
type EmojiReaction = z.infer<typeof emojiReactionSchema>;
export { emojiReactionSchema, EmojiReaction };
export { emojiReactionSchema, type EmojiReaction };

View File

@ -16,4 +16,4 @@ const groupMemberSchema = z.object({
type GroupMember = z.infer<typeof groupMemberSchema>;
export { groupMemberSchema, GroupMember, GroupRoles };
export { groupMemberSchema, type GroupMember, GroupRoles };

View File

@ -14,4 +14,4 @@ const groupRelationshipSchema = z.object({
type GroupRelationship = z.infer<typeof groupRelationshipSchema>;
export { groupRelationshipSchema, GroupRelationship };
export { groupRelationshipSchema, type GroupRelationship };

View File

@ -1,13 +1,18 @@
export { accountSchema, type Account } from './account';
export { attachmentSchema, type Attachment } from './attachment';
export { cardSchema, type Card } from './card';
export { chatMessageSchema, type ChatMessage } from './chat-message';
export { customEmojiSchema, type CustomEmoji } from './custom-emoji';
export { emojiReactionSchema, type EmojiReaction } from './emoji-reaction';
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 { mentionSchema, type Mention } from './mention';
export { notificationSchema, type Notification } from './notification';
export { pollSchema, type Poll, type PollOption } from './poll';
export { relationshipSchema, type Relationship } from './relationship';
export { statusSchema, type Status } from './status';
export { tagSchema, type Tag } from './tag';
// Soapbox

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
const mentionSchema = z.object({
acct: z.string(),
id: z.string(),
url: z.string().url().catch(''),
username: z.string().catch(''),
}).transform((mention) => {
if (!mention.username) {
mention.username = mention.acct.split('@')[0];
}
return mention;
});
type Mention = z.infer<typeof mentionSchema>;
export { mentionSchema, type Mention };

View File

@ -0,0 +1,104 @@
import { z } from 'zod';
import { accountSchema } from './account';
import { chatMessageSchema } from './chat-message';
import { statusSchema } from './status';
import { emojiSchema } from './utils';
const baseNotificationSchema = z.object({
account: accountSchema,
created_at: z.string().datetime().catch(new Date().toUTCString()),
id: z.string(),
type: z.string(),
total_count: z.number().optional().catch(undefined), // TruthSocial
});
const mentionNotificationSchema = baseNotificationSchema.extend({
type: z.literal('mention'),
status: statusSchema,
});
const statusNotificationSchema = baseNotificationSchema.extend({
type: z.literal('status'),
status: statusSchema,
});
const reblogNotificationSchema = baseNotificationSchema.extend({
type: z.literal('reblog'),
status: statusSchema,
});
const followNotificationSchema = baseNotificationSchema.extend({
type: z.literal('follow'),
});
const followRequestNotificationSchema = baseNotificationSchema.extend({
type: z.literal('follow_request'),
});
const favouriteNotificationSchema = baseNotificationSchema.extend({
type: z.literal('favourite'),
status: statusSchema,
});
const pollNotificationSchema = baseNotificationSchema.extend({
type: z.literal('poll'),
status: statusSchema,
});
const updateNotificationSchema = baseNotificationSchema.extend({
type: z.literal('update'),
status: statusSchema,
});
const moveNotificationSchema = baseNotificationSchema.extend({
type: z.literal('move'),
target: accountSchema,
});
const chatMessageNotificationSchema = baseNotificationSchema.extend({
type: z.literal('chat_message'),
chat_message: chatMessageSchema,
});
const emojiReactionNotificationSchema = baseNotificationSchema.extend({
type: z.literal('pleroma:emoji_reaction'),
emoji: emojiSchema,
emoji_url: z.string().url().optional().catch(undefined),
});
const eventReminderNotificationSchema = baseNotificationSchema.extend({
type: z.literal('pleroma:event_reminder'),
status: statusSchema,
});
const participationRequestNotificationSchema = baseNotificationSchema.extend({
type: z.literal('pleroma:participation_request'),
status: statusSchema,
});
const participationAcceptedNotificationSchema = baseNotificationSchema.extend({
type: z.literal('pleroma:participation_accepted'),
status: statusSchema,
});
const notificationSchema = z.discriminatedUnion('type', [
mentionNotificationSchema,
statusNotificationSchema,
reblogNotificationSchema,
followNotificationSchema,
followRequestNotificationSchema,
favouriteNotificationSchema,
pollNotificationSchema,
updateNotificationSchema,
moveNotificationSchema,
chatMessageNotificationSchema,
emojiReactionNotificationSchema,
eventReminderNotificationSchema,
participationRequestNotificationSchema,
participationAcceptedNotificationSchema,
]);
type Notification = z.infer<typeof notificationSchema>;
export { notificationSchema, type Notification };

View File

@ -1,9 +1,60 @@
import { z } from 'zod';
import { normalizeStatus } from 'soapbox/normalizers';
import { toSchema } from 'soapbox/utils/normalizers';
import { accountSchema } from './account';
import { attachmentSchema } from './attachment';
import { cardSchema } from './card';
import { customEmojiSchema } from './custom-emoji';
import { groupSchema } from './group';
import { mentionSchema } from './mention';
import { pollSchema } from './poll';
import { tagSchema } from './tag';
import { contentSchema, dateSchema, filteredArray } from './utils';
const statusSchema = toSchema(normalizeStatus);
const baseStatusSchema = z.object({
account: accountSchema,
application: z.object({
name: z.string(),
website: z.string().url().nullable().catch(null),
}).nullable().catch(null),
bookmarked: z.coerce.boolean(),
card: cardSchema.nullable().catch(null),
content: contentSchema,
created_at: dateSchema,
disliked: z.coerce.boolean(),
dislikes_count: z.number().catch(0),
edited_at: z.string().datetime().nullable().catch(null),
emojis: filteredArray(customEmojiSchema),
favourited: z.coerce.boolean(),
favourites_count: z.number().catch(0),
group: groupSchema.nullable().catch(null),
in_reply_to_account_id: z.string().nullable().catch(null),
in_reply_to_id: z.string().nullable().catch(null),
id: z.string(),
language: z.string().nullable().catch(null),
media_attachments: filteredArray(attachmentSchema),
mentions: filteredArray(mentionSchema),
muted: z.coerce.boolean(),
pinned: z.coerce.boolean(),
pleroma: z.object({}).optional().catch(undefined),
poll: pollSchema.nullable().catch(null),
quote: z.literal(null).catch(null),
quotes_count: z.number().catch(0),
reblog: z.literal(null).catch(null),
reblogged: z.coerce.boolean(),
reblogs_count: z.number().catch(0),
replies_count: z.number().catch(0),
sensitive: z.coerce.boolean(),
spoiler_text: contentSchema,
tags: filteredArray(tagSchema),
uri: z.string().url().catch(''),
url: z.string().url().catch(''),
visibility: z.string().catch('public'),
});
const statusSchema = baseStatusSchema.extend({
quote: baseStatusSchema.nullable().catch(null),
reblog: baseStatusSchema.nullable().catch(null),
});
type Status = z.infer<typeof statusSchema>;

View File

@ -5,7 +5,7 @@ const historySchema = z.object({
uses: z.coerce.number(),
});
/** // https://docs.joinmastodon.org/entities/tag */
/** https://docs.joinmastodon.org/entities/tag */
const tagSchema = z.object({
name: z.string().min(1),
url: z.string().url().catch(''),

View File

@ -2,6 +2,12 @@ import z from 'zod';
import type { CustomEmoji } from './custom-emoji';
/** Ensure HTML content is a string, and drop empty `<p>` tags. */
const contentSchema = z.string().catch('').transform((value) => value === '<p></p>' ? '' : value);
/** Validate to Mastodon's date format, or use the current date. */
const dateSchema = z.string().datetime().catch(new Date().toUTCString());
/** Validates individual items in an array, dropping any that aren't valid. */
function filteredArray<T extends z.ZodTypeAny>(schema: T) {
return z.any().array().catch([])
@ -13,6 +19,9 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
));
}
/** Validates the string as an emoji. */
const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v));
/** Map a list of CustomEmoji to their shortcodes. */
function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
return customEmojis.reduce<Record<string, CustomEmoji>>((result, emoji) => {
@ -21,4 +30,4 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
}, {});
}
export { filteredArray, makeCustomEmojiMap };
export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema };