kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'schema-improvements' into 'develop'
Improve schemas for Account, EmojiReaction, Location, and Status See merge request soapbox-pub/soapbox!2555environments/review-develop-3zknud/deployments/3501
commit
dadc39f731
|
@ -2,123 +2,148 @@ import escapeTextContentForBrowser from 'escape-html';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
import emojify from 'soapbox/features/emoji';
|
import emojify from 'soapbox/features/emoji';
|
||||||
|
import { unescapeHTML } from 'soapbox/utils/html';
|
||||||
|
|
||||||
import { customEmojiSchema } from './custom-emoji';
|
import { customEmojiSchema } from './custom-emoji';
|
||||||
import { relationshipSchema } from './relationship';
|
|
||||||
import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils';
|
import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils';
|
||||||
|
|
||||||
|
import type { Resolve } from 'soapbox/utils/types';
|
||||||
|
|
||||||
const avatarMissing = require('assets/images/avatar-missing.png');
|
const avatarMissing = require('assets/images/avatar-missing.png');
|
||||||
const headerMissing = require('assets/images/header-missing.png');
|
const headerMissing = require('assets/images/header-missing.png');
|
||||||
|
|
||||||
const accountSchema = z.object({
|
const birthdaySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
accepting_messages: z.boolean().catch(false),
|
|
||||||
accepts_chat_messages: z.boolean().catch(false),
|
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(''),
|
acct: z.string().catch(''),
|
||||||
avatar: z.string().catch(avatarMissing),
|
avatar: z.string().catch(avatarMissing),
|
||||||
avatar_static: z.string().catch(''),
|
avatar_static: z.string().url().optional().catch(undefined),
|
||||||
birthday: z.string().catch(''),
|
|
||||||
bot: z.boolean().catch(false),
|
bot: z.boolean().catch(false),
|
||||||
chats_onboarded: z.boolean().catch(true),
|
|
||||||
created_at: z.string().datetime().catch(new Date().toUTCString()),
|
created_at: z.string().datetime().catch(new Date().toUTCString()),
|
||||||
discoverable: z.boolean().catch(false),
|
discoverable: z.boolean().catch(false),
|
||||||
display_name: z.string().catch(''),
|
display_name: z.string().catch(''),
|
||||||
emojis: filteredArray(customEmojiSchema),
|
emojis: filteredArray(customEmojiSchema),
|
||||||
favicon: z.string().catch(''),
|
favicon: z.string().catch(''),
|
||||||
fields: z.any(), // TODO
|
fields: filteredArray(fieldSchema),
|
||||||
followers_count: z.number().catch(0),
|
followers_count: z.number().catch(0),
|
||||||
following_count: z.number().catch(0),
|
following_count: z.number().catch(0),
|
||||||
fqn: z.string().catch(''),
|
fqn: z.string().optional().catch(undefined),
|
||||||
header: z.string().catch(headerMissing),
|
header: z.string().url().catch(headerMissing),
|
||||||
header_static: z.string().catch(''),
|
header_static: z.string().url().optional().catch(undefined),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
last_status_at: z.string().catch(''),
|
last_status_at: z.string().datetime().optional().catch(undefined),
|
||||||
location: z.string().catch(''),
|
location: z.string().optional().catch(undefined),
|
||||||
locked: z.boolean().catch(false),
|
locked: z.boolean().catch(false),
|
||||||
moved: z.any(), // TODO
|
moved: z.literal(null).catch(null),
|
||||||
mute_expires_at: z.union([
|
mute_expires_at: z.union([
|
||||||
z.string(),
|
z.string(),
|
||||||
z.null(),
|
z.null(),
|
||||||
]).catch(null),
|
]).catch(null),
|
||||||
note: contentSchema,
|
note: contentSchema,
|
||||||
pleroma: z.any(), // TODO
|
/** Fedibird extra settings. */
|
||||||
source: z.any(), // TODO
|
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),
|
statuses_count: z.number().catch(0),
|
||||||
|
suspended: z.boolean().catch(false),
|
||||||
uri: z.string().url().catch(''),
|
uri: z.string().url().catch(''),
|
||||||
url: z.string().url().catch(''),
|
url: z.string().url().catch(''),
|
||||||
username: z.string().catch(''),
|
username: z.string().catch(''),
|
||||||
verified: z.boolean().default(false),
|
verified: z.boolean().catch(false),
|
||||||
website: z.string().catch(''),
|
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) => {
|
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Account = z.infer<typeof accountSchema>;
|
type BaseAccount = z.infer<typeof baseAccountSchema>;
|
||||||
|
type TransformableAccount = Omit<BaseAccount, 'moved'>;
|
||||||
|
|
||||||
|
const getDomain = (url: string) => {
|
||||||
|
try {
|
||||||
|
return new URL(url).host;
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Add internal fields to the account. */
|
||||||
|
const transformAccount = <T extends TransformableAccount>({ pleroma, other_settings, fields, ...account }: T) => {
|
||||||
|
const customEmojiMap = makeCustomEmojiMap(account.emojis);
|
||||||
|
|
||||||
|
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 = Resolve<z.infer<typeof accountSchema>>;
|
||||||
|
|
||||||
export { accountSchema, type Account };
|
export { accountSchema, type Account };
|
|
@ -7,6 +7,8 @@ const emojiReactionSchema = z.object({
|
||||||
name: emojiSchema,
|
name: emojiSchema,
|
||||||
count: z.number().nullable().catch(null),
|
count: z.number().nullable().catch(null),
|
||||||
me: z.boolean().catch(false),
|
me: z.boolean().catch(false),
|
||||||
|
/** Akkoma custom emoji reaction. */
|
||||||
|
url: z.string().url().optional().catch(undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
type EmojiReaction = z.infer<typeof emojiReactionSchema>;
|
type EmojiReaction = z.infer<typeof emojiReactionSchema>;
|
||||||
|
|
|
@ -12,6 +12,9 @@ const locationSchema = z.object({
|
||||||
origin_provider: z.string().catch(''),
|
origin_provider: z.string().catch(''),
|
||||||
type: z.string().catch(''),
|
type: z.string().catch(''),
|
||||||
timezone: z.string().catch(''),
|
timezone: z.string().catch(''),
|
||||||
|
name: z.string().catch(''),
|
||||||
|
latitude: z.number().catch(0),
|
||||||
|
longitude: z.number().catch(0),
|
||||||
geom: z.object({
|
geom: z.object({
|
||||||
coordinates: z.tuple([z.number(), z.number()]).nullable().catch(null),
|
coordinates: z.tuple([z.number(), z.number()]).nullable().catch(null),
|
||||||
srid: z.string().catch(''),
|
srid: z.string().catch(''),
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { accountSchema } from './account';
|
||||||
import { attachmentSchema } from './attachment';
|
import { attachmentSchema } from './attachment';
|
||||||
import { cardSchema } from './card';
|
import { cardSchema } from './card';
|
||||||
import { customEmojiSchema } from './custom-emoji';
|
import { customEmojiSchema } from './custom-emoji';
|
||||||
|
import { emojiReactionSchema } from './emoji-reaction';
|
||||||
import { eventSchema } from './event';
|
import { eventSchema } from './event';
|
||||||
import { groupSchema } from './group';
|
import { groupSchema } from './group';
|
||||||
import { mentionSchema } from './mention';
|
import { mentionSchema } from './mention';
|
||||||
|
@ -15,6 +16,15 @@ import { pollSchema } from './poll';
|
||||||
import { tagSchema } from './tag';
|
import { tagSchema } from './tag';
|
||||||
import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils';
|
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),
|
||||||
|
quote: z.literal(null).catch(null),
|
||||||
|
quote_visible: z.boolean().catch(true),
|
||||||
|
});
|
||||||
|
|
||||||
const baseStatusSchema = z.object({
|
const baseStatusSchema = z.object({
|
||||||
account: accountSchema,
|
account: accountSchema,
|
||||||
application: z.object({
|
application: z.object({
|
||||||
|
@ -40,12 +50,11 @@ const baseStatusSchema = z.object({
|
||||||
mentions: filteredArray(mentionSchema),
|
mentions: filteredArray(mentionSchema),
|
||||||
muted: z.coerce.boolean(),
|
muted: z.coerce.boolean(),
|
||||||
pinned: z.coerce.boolean(),
|
pinned: z.coerce.boolean(),
|
||||||
pleroma: z.object({
|
pleroma: statusPleromaSchema.optional().catch(undefined),
|
||||||
quote_visible: z.boolean().catch(true),
|
|
||||||
}).optional().catch(undefined),
|
|
||||||
poll: pollSchema.nullable().catch(null),
|
poll: pollSchema.nullable().catch(null),
|
||||||
quote: z.literal(null).catch(null),
|
quote: z.literal(null).catch(null),
|
||||||
quotes_count: z.number().catch(0),
|
quotes_count: z.number().catch(0),
|
||||||
|
reblog: z.literal(null).catch(null),
|
||||||
reblogged: z.coerce.boolean(),
|
reblogged: z.coerce.boolean(),
|
||||||
reblogs_count: z.number().catch(0),
|
reblogs_count: z.number().catch(0),
|
||||||
replies_count: z.number().catch(0),
|
replies_count: z.number().catch(0),
|
||||||
|
@ -61,7 +70,9 @@ const baseStatusSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
type BaseStatus = z.infer<typeof baseStatusSchema>;
|
type BaseStatus = z.infer<typeof baseStatusSchema>;
|
||||||
type TransformableStatus = Omit<BaseStatus, 'reblog' | 'quote' | 'pleroma'>;
|
type TransformableStatus = Omit<BaseStatus, 'reblog' | 'quote' | 'pleroma'> & {
|
||||||
|
pleroma?: Omit<z.infer<typeof statusPleromaSchema>, 'quote'>
|
||||||
|
};
|
||||||
|
|
||||||
/** Creates search index from the status. */
|
/** Creates search index from the status. */
|
||||||
const buildSearchIndex = (status: TransformableStatus): string => {
|
const buildSearchIndex = (status: TransformableStatus): string => {
|
||||||
|
@ -85,7 +96,7 @@ type Translation = {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add internal fields to the status. */
|
/** Add internal fields to the status. */
|
||||||
const transformStatus = <T extends TransformableStatus>(status: T) => {
|
const transformStatus = <T extends TransformableStatus>({ pleroma, ...status }: T) => {
|
||||||
const emojiMap = makeCustomEmojiMap(status.emojis);
|
const emojiMap = makeCustomEmojiMap(status.emojis);
|
||||||
|
|
||||||
const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap));
|
const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap));
|
||||||
|
@ -93,15 +104,20 @@ const transformStatus = <T extends TransformableStatus>(status: T) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...status,
|
...status,
|
||||||
contentHtml,
|
|
||||||
spoilerHtml,
|
|
||||||
search_index: buildSearchIndex(status),
|
|
||||||
hidden: false,
|
|
||||||
filtered: [],
|
|
||||||
showFiltered: false, // TODO: this should be removed from the schema and done somewhere else
|
|
||||||
approval_status: 'approval' as const,
|
approval_status: 'approval' as const,
|
||||||
translation: undefined as Translation | undefined,
|
contentHtml,
|
||||||
expectsCard: 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
|
||||||
|
spoilerHtml,
|
||||||
|
translation: undefined as Translation | undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -113,10 +129,8 @@ const embeddedStatusSchema = baseStatusSchema
|
||||||
const statusSchema = baseStatusSchema.extend({
|
const statusSchema = baseStatusSchema.extend({
|
||||||
quote: embeddedStatusSchema,
|
quote: embeddedStatusSchema,
|
||||||
reblog: embeddedStatusSchema,
|
reblog: embeddedStatusSchema,
|
||||||
pleroma: z.object({
|
pleroma: statusPleromaSchema.extend({
|
||||||
event: eventSchema,
|
|
||||||
quote: embeddedStatusSchema,
|
quote: embeddedStatusSchema,
|
||||||
quote_visible: z.boolean().catch(true),
|
|
||||||
}).optional().catch(undefined),
|
}).optional().catch(undefined),
|
||||||
}).transform(({ pleroma, ...status }) => {
|
}).transform(({ pleroma, ...status }) => {
|
||||||
return {
|
return {
|
||||||
|
@ -132,6 +146,6 @@ const statusSchema = baseStatusSchema.extend({
|
||||||
};
|
};
|
||||||
}).transform(transformStatus);
|
}).transform(transformStatus);
|
||||||
|
|
||||||
type Status = z.infer<typeof statusSchema>;
|
type Status = Resolve<z.infer<typeof statusSchema>>;
|
||||||
|
|
||||||
export { statusSchema, type Status };
|
export { statusSchema, type Status };
|
|
@ -23,7 +23,7 @@ export const getBaseURL = (account: AccountEntity): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAcct = (account: AccountEntity | Account, displayFqn: boolean): string => (
|
export const getAcct = (account: Pick<Account, 'fqn' | 'acct'>, displayFqn: boolean): string => (
|
||||||
displayFqn === true ? account.fqn : account.acct
|
displayFqn === true ? account.fqn : account.acct
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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<T> = Pick<T, keyof T>;
|
||||||
|
|
||||||
|
export type { Resolve };
|
Ładowanie…
Reference in New Issue