From 263db3e1f0793d384343567ae9e8335ec107d859 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Jun 2023 14:33:19 -0500 Subject: [PATCH 1/3] Improve schemas for Account, EmojiReaction, Location, and Status --- app/soapbox/schemas/account.ts | 191 +++++++++++++++----------- app/soapbox/schemas/emoji-reaction.ts | 2 + app/soapbox/schemas/location.ts | 3 + app/soapbox/schemas/status.ts | 38 +++-- app/soapbox/utils/accounts.ts | 4 +- 5 files changed, 139 insertions(+), 99 deletions(-) diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index f2e5f9c15..f0fdf4887 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -2,122 +2,145 @@ 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 { relationshipSchema } from './relationship'; import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils'; const avatarMissing = require('assets/images/avatar-missing.png'); const headerMissing = require('assets/images/header-missing.png'); -const accountSchema = z.object({ - accepting_messages: z.boolean().catch(false), - accepts_chat_messages: z.boolean().catch(false), +const birthdaySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + +const fieldSchema = z.object({ + name: z.string(), + value: z.string(), + verified_at: z.string().datetime().nullable().catch(null), +}); + +const baseAccountSchema = z.object({ acct: z.string().catch(''), avatar: z.string().catch(avatarMissing), - avatar_static: z.string().catch(''), - birthday: z.string().catch(''), + avatar_static: z.string().url().optional().catch(undefined), bot: z.boolean().catch(false), - chats_onboarded: z.boolean().catch(true), created_at: z.string().datetime().catch(new Date().toUTCString()), discoverable: z.boolean().catch(false), display_name: z.string().catch(''), emojis: filteredArray(customEmojiSchema), favicon: z.string().catch(''), - fields: z.any(), // TODO + fields: filteredArray(fieldSchema), followers_count: z.number().catch(0), following_count: z.number().catch(0), - fqn: z.string().catch(''), - header: z.string().catch(headerMissing), - header_static: z.string().catch(''), + fqn: z.string().optional().catch(undefined), + header: z.string().url().catch(headerMissing), + header_static: z.string().url().optional().catch(undefined), id: z.string(), - last_status_at: z.string().catch(''), - location: z.string().catch(''), + last_status_at: z.string().datetime().optional().catch(undefined), + location: z.string().optional().catch(undefined), locked: z.boolean().catch(false), - moved: z.any(), // TODO + moved: z.literal(null).catch(null), mute_expires_at: z.union([ z.string(), z.null(), ]).catch(null), note: contentSchema, - pleroma: z.any(), // TODO - source: z.any(), // TODO + /** Fedibird extra settings. */ + other_settings: z.object({ + birthday: birthdaySchema.nullish().catch(undefined), + location: z.string().optional().catch(undefined), + }).optional().catch(undefined), + pleroma: z.object({ + accepts_chat_messages: z.boolean().catch(false), + accepts_email_list: z.boolean().catch(false), + birthday: birthdaySchema.nullish().catch(undefined), + deactivated: z.boolean().catch(false), + favicon: z.string().url().optional().catch(undefined), + hide_favorites: z.boolean().catch(false), + hide_followers: z.boolean().catch(false), + hide_followers_count: z.boolean().catch(false), + hide_follows: z.boolean().catch(false), + hide_follows_count: z.boolean().catch(false), + is_admin: z.boolean().catch(false), + is_moderator: z.boolean().catch(false), + is_suggested: z.boolean().catch(false), + location: z.string().optional().catch(undefined), + notification_settings: z.object({ + block_from_strangers: z.boolean().catch(false), + }).optional().catch(undefined), + tags: z.array(z.string()).catch([]), + }).optional().catch(undefined), + source: z.object({ + approved: z.boolean().catch(true), + chats_onboarded: z.boolean().catch(true), + fields: filteredArray(fieldSchema), + note: z.string().catch(''), + pleroma: z.object({ + discoverable: z.boolean().catch(true), + }).optional().catch(undefined), + sms_verified: z.boolean().catch(false), + }).optional().catch(undefined), statuses_count: z.number().catch(0), + suspended: z.boolean().catch(false), uri: z.string().url().catch(''), url: z.string().url().catch(''), username: z.string().catch(''), - verified: z.boolean().default(false), + verified: z.boolean().catch(false), website: z.string().catch(''), +}); - /* - * Internal fields - */ - display_name_html: z.string().catch(''), - domain: z.string().catch(''), - note_emojified: z.string().catch(''), - relationship: relationshipSchema.nullable().catch(null), - - /* - * Misc - */ - other_settings: z.any(), -}).transform((account) => { +type BaseAccount = z.infer; +type TransformableAccount = Omit; + +const getDomain = (url: string) => { + try { + return new URL(url).host; + } catch (e) { + return ''; + } +}; + +/** Add internal fields to the account. */ +const transformAccount = ({ pleroma, other_settings, fields, ...account }: T) => { const customEmojiMap = makeCustomEmojiMap(account.emojis); - // Birthday - const birthday = account.pleroma?.birthday || account.other_settings?.birthday; - account.birthday = birthday; - - // Verified - const verified = account.verified === true || account.pleroma?.tags?.includes('verified'); - account.verified = verified; - - // Location - const location = account.location - || account.pleroma?.location - || account.other_settings?.location; - account.location = location; - - // Username - const acct = account.acct || ''; - const username = account.username || ''; - account.username = username || acct.split('@')[0]; - - // Display Name - const displayName = account.display_name || ''; - account.display_name = displayName.trim().length === 0 ? account.username : displayName; - account.display_name_html = emojify(escapeTextContentForBrowser(displayName), customEmojiMap); - - // Discoverable - const discoverable = Boolean(account.discoverable || account.source?.pleroma?.discoverable); - account.discoverable = discoverable; - - // Message Acceptance - const acceptsChatMessages = Boolean(account.pleroma?.accepts_chat_messages || account?.accepting_messages); - account.accepts_chat_messages = acceptsChatMessages; - - // Notes - account.note_emojified = emojify(account.note, customEmojiMap); - - /* - * Todo - * - internal fields - * - donor - * - tags - * - fields - * - pleroma legacy fields - * - emojification - * - domain - * - guessFqn - * - fqn - * - favicon - * - staff fields - * - birthday - * - note - */ - - return account; -}); + const newFields = fields.map((field) => ({ + ...field, + name_emojified: emojify(escapeTextContentForBrowser(field.name), customEmojiMap), + value_emojified: emojify(field.value, customEmojiMap), + value_plain: unescapeHTML(field.value), + })); + + const domain = getDomain(account.url || account.uri); + + if (pleroma) { + pleroma.birthday = pleroma.birthday || other_settings?.birthday; + } + + return { + ...account, + admin: pleroma?.is_admin || false, + avatar_static: account.avatar_static || account.avatar, + discoverable: account.discoverable || account.source?.pleroma?.discoverable || false, + display_name: account.display_name.trim().length === 0 ? account.username : account.display_name, + display_name_html: emojify(escapeTextContentForBrowser(account.display_name), customEmojiMap), + domain, + fields: newFields, + fqn: account.fqn || (account.acct.includes('@') ? account.acct : `${account.acct}@${domain}`), + header_static: account.header_static || account.header, + moderator: pleroma?.is_moderator || false, + location: account.location || pleroma?.location || other_settings?.location || '', + note_emojified: emojify(account.note, customEmojiMap), + pleroma, + relationship: undefined, + staff: pleroma?.is_admin || pleroma?.is_moderator || false, + suspended: account.suspended || pleroma?.deactivated || false, + verified: account.verified || pleroma?.tags.includes('verified') || false, + }; +}; + +const accountSchema = baseAccountSchema.extend({ + moved: baseAccountSchema.transform(transformAccount).nullable().catch(null), +}).transform(transformAccount); type Account = z.infer; diff --git a/app/soapbox/schemas/emoji-reaction.ts b/app/soapbox/schemas/emoji-reaction.ts index 1559148e1..56998c625 100644 --- a/app/soapbox/schemas/emoji-reaction.ts +++ b/app/soapbox/schemas/emoji-reaction.ts @@ -7,6 +7,8 @@ const emojiReactionSchema = z.object({ name: emojiSchema, count: z.number().nullable().catch(null), me: z.boolean().catch(false), + /** Akkoma custom emoji reaction. */ + url: z.string().url().optional().catch(undefined), }); type EmojiReaction = z.infer; diff --git a/app/soapbox/schemas/location.ts b/app/soapbox/schemas/location.ts index cbc237222..a0435f1f3 100644 --- a/app/soapbox/schemas/location.ts +++ b/app/soapbox/schemas/location.ts @@ -12,6 +12,9 @@ const locationSchema = z.object({ origin_provider: z.string().catch(''), type: z.string().catch(''), timezone: z.string().catch(''), + name: z.string().catch(''), + latitude: z.number().catch(0), + longitude: z.number().catch(0), geom: z.object({ coordinates: z.tuple([z.number(), z.number()]).nullable().catch(null), srid: z.string().catch(''), diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index 323ca5789..de06ecb98 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -8,6 +8,7 @@ import { accountSchema } from './account'; import { attachmentSchema } from './attachment'; import { cardSchema } from './card'; import { customEmojiSchema } from './custom-emoji'; +import { emojiReactionSchema } from './emoji-reaction'; import { eventSchema } from './event'; import { groupSchema } from './group'; import { mentionSchema } from './mention'; @@ -15,6 +16,13 @@ import { pollSchema } from './poll'; import { tagSchema } from './tag'; import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils'; +const statusPleromaSchema = z.object({ + emoji_reactions: filteredArray(emojiReactionSchema), + event: eventSchema.nullish().catch(undefined), + quote: z.literal(null).catch(null), + quote_visible: z.boolean().catch(true), +}); + const baseStatusSchema = z.object({ account: accountSchema, application: z.object({ @@ -40,12 +48,11 @@ const baseStatusSchema = z.object({ mentions: filteredArray(mentionSchema), muted: z.coerce.boolean(), pinned: z.coerce.boolean(), - pleroma: z.object({ - quote_visible: z.boolean().catch(true), - }).optional().catch(undefined), + pleroma: statusPleromaSchema.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), @@ -61,7 +68,9 @@ const baseStatusSchema = z.object({ }); type BaseStatus = z.infer; -type TransformableStatus = Omit; +type TransformableStatus = Omit & { + pleroma?: Omit, 'quote'> +}; /** Creates search index from the status. */ const buildSearchIndex = (status: TransformableStatus): string => { @@ -85,7 +94,7 @@ type Translation = { } /** Add internal fields to the status. */ -const transformStatus = (status: T) => { +const transformStatus = ({ pleroma, ...status }: T) => { const emojiMap = makeCustomEmojiMap(status.emojis); const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap)); @@ -93,15 +102,20 @@ const transformStatus = (status: T) => { return { ...status, + approval_status: 'approval' as const, contentHtml, - spoilerHtml, - search_index: buildSearchIndex(status), - hidden: false, + expectsCard: false, + event: pleroma?.event, filtered: [], + hidden: false, + pleroma: pleroma ? (() => { + const { event, ...rest } = pleroma; + return rest; + })() : undefined, + search_index: buildSearchIndex(status), showFiltered: false, // TODO: this should be removed from the schema and done somewhere else - approval_status: 'approval' as const, + spoilerHtml, translation: undefined as Translation | undefined, - expectsCard: false, }; }; @@ -113,10 +127,8 @@ const embeddedStatusSchema = baseStatusSchema const statusSchema = baseStatusSchema.extend({ quote: embeddedStatusSchema, reblog: embeddedStatusSchema, - pleroma: z.object({ - event: eventSchema, + pleroma: statusPleromaSchema.extend({ quote: embeddedStatusSchema, - quote_visible: z.boolean().catch(true), }).optional().catch(undefined), }).transform(({ pleroma, ...status }) => { return { diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index 47781ffbf..e0995416f 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -23,8 +23,8 @@ export const getBaseURL = (account: AccountEntity): string => { } }; -export const getAcct = (account: AccountEntity | Account, displayFqn: boolean): string => ( - displayFqn === true ? account.fqn : account.acct +export const getAcct = (account: Pick, displayFqn: boolean): string => ( + displayFqn === true ? account.fqn as string : account.acct ); export const isLocal = (account: AccountEntity | Account): boolean => { From db070150d914be178e1c72e55fa3b7e1c3ae14c4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Jun 2023 20:59:38 -0500 Subject: [PATCH 2/3] Remove unnecessary `as string` --- app/soapbox/utils/accounts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index e0995416f..ef7c446a5 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -24,7 +24,7 @@ export const getBaseURL = (account: AccountEntity): string => { }; export const getAcct = (account: Pick, displayFqn: boolean): string => ( - displayFqn === true ? account.fqn as string : account.acct + displayFqn === true ? account.fqn : account.acct ); export const isLocal = (account: AccountEntity | Account): boolean => { From 60eaf01940bc0fda24dcabcd5316e2d63e6dec43 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Jun 2023 22:12:42 -0500 Subject: [PATCH 3/3] Add Resolve utility type --- app/soapbox/schemas/account.ts | 4 +++- app/soapbox/schemas/status.ts | 4 +++- app/soapbox/utils/types.ts | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 app/soapbox/utils/types.ts diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index f0fdf4887..6c41bebc4 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -7,6 +7,8 @@ import { unescapeHTML } from 'soapbox/utils/html'; import { customEmojiSchema } from './custom-emoji'; import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils'; +import type { Resolve } from 'soapbox/utils/types'; + const avatarMissing = require('assets/images/avatar-missing.png'); const headerMissing = require('assets/images/header-missing.png'); @@ -142,6 +144,6 @@ const accountSchema = baseAccountSchema.extend({ moved: baseAccountSchema.transform(transformAccount).nullable().catch(null), }).transform(transformAccount); -type Account = z.infer; +type Account = Resolve>; export { accountSchema, type Account }; \ No newline at end of file diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index de06ecb98..accd31a6a 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -16,6 +16,8 @@ import { pollSchema } from './poll'; import { tagSchema } from './tag'; import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils'; +import type { Resolve } from 'soapbox/utils/types'; + const statusPleromaSchema = z.object({ emoji_reactions: filteredArray(emojiReactionSchema), event: eventSchema.nullish().catch(undefined), @@ -144,6 +146,6 @@ const statusSchema = baseStatusSchema.extend({ }; }).transform(transformStatus); -type Status = z.infer; +type Status = Resolve>; export { statusSchema, type Status }; \ No newline at end of file diff --git a/app/soapbox/utils/types.ts b/app/soapbox/utils/types.ts new file mode 100644 index 000000000..31eacd481 --- /dev/null +++ b/app/soapbox/utils/types.ts @@ -0,0 +1,7 @@ +/** + * Resolve a type into a flat POJO interface if it's been wrapped by generics. + * https://gleasonator.com/@alex/posts/AWfK4hyppMDCqrT2y8 + */ +type Resolve = Pick; + +export type { Resolve }; \ No newline at end of file