diff --git a/app/soapbox/schemas/event.ts b/app/soapbox/schemas/event.ts new file mode 100644 index 000000000..e74a80760 --- /dev/null +++ b/app/soapbox/schemas/event.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { attachmentSchema } from './attachment'; +import { locationSchema } from './location'; + +const eventSchema = z.object({ + name: z.string().catch(''), + start_time: z.string().datetime().nullable().catch(null), + end_time: z.string().datetime().nullable().catch(null), + join_mode: z.enum(['free', 'restricted', 'invite']).nullable().catch(null), + participants_count: z.number().catch(0), + location: locationSchema.nullable().catch(null), + join_state: z.enum(['pending', 'reject', 'accept']).nullable().catch(null), + banner: attachmentSchema.nullable().catch(null), + links: z.array(attachmentSchema).nullable().catch(null), +}); + +type Event = z.infer; + +export { eventSchema, type Event }; \ No newline at end of file diff --git a/app/soapbox/schemas/location.ts b/app/soapbox/schemas/location.ts new file mode 100644 index 000000000..cbc237222 --- /dev/null +++ b/app/soapbox/schemas/location.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +const locationSchema = z.object({ + url: z.string().url().catch(''), + description: z.string().catch(''), + country: z.string().catch(''), + locality: z.string().catch(''), + region: z.string().catch(''), + postal_code: z.string().catch(''), + street: z.string().catch(''), + origin_id: z.string().catch(''), + origin_provider: z.string().catch(''), + type: z.string().catch(''), + timezone: z.string().catch(''), + geom: z.object({ + coordinates: z.tuple([z.number(), z.number()]).nullable().catch(null), + srid: z.string().catch(''), + }).nullable().catch(null), +}); + +type Location = z.infer; + +export { locationSchema, type Location }; \ No newline at end of file diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index 4905fb5a3..edb585aec 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -2,22 +2,19 @@ import escapeTextContentForBrowser from 'escape-html'; import { z } from 'zod'; import emojify from 'soapbox/features/emoji'; -import { stripCompatibilityFeatures } from 'soapbox/utils/html'; +import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html'; import { accountSchema } from './account'; import { attachmentSchema } from './attachment'; import { cardSchema } from './card'; import { customEmojiSchema } from './custom-emoji'; +import { eventSchema } from './event'; import { groupSchema } from './group'; import { mentionSchema } from './mention'; import { pollSchema } from './poll'; import { tagSchema } from './tag'; import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils'; -const tombstoneSchema = z.object({ - reason: z.enum(['deleted']), -}); - const baseStatusSchema = z.object({ account: accountSchema, application: z.object({ @@ -43,27 +40,44 @@ const baseStatusSchema = z.object({ 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), - tombstone: tombstoneSchema.nullable().optional(), + tombstone: z.object({ + reason: z.enum(['deleted']), + }).nullable().optional().catch(undefined), 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), -}).transform((status) => { +type BaseStatus = z.infer; +type TransformableStatus = Omit; + +/** Creates search index from the status. */ +const buildSearchIndex = (status: TransformableStatus): string => { + const pollOptionTitles = status.poll ? status.poll.options.map(({ title }) => title) : []; + const mentionedUsernames = status.mentions.map(({ acct }) => `@${acct}`); + + const fields = [ + status.spoiler_text, + status.content, + ...pollOptionTitles, + ...mentionedUsernames, + ]; + + const searchContent = unescapeHTML(fields.join('\n\n')) || ''; + return new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent || ''; +}; + +/** Add internal fields to the status. */ +const transformStatus = (status: T) => { const emojiMap = makeCustomEmojiMap(status.emojis); const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap)); @@ -73,8 +87,30 @@ const statusSchema = baseStatusSchema.extend({ ...status, contentHtml, spoilerHtml, + search_index: buildSearchIndex(status), + hidden: false, }; -}); +}; + +const embeddedStatusSchema = baseStatusSchema + .transform(transformStatus) + .nullable() + .catch(null); + +const statusSchema = baseStatusSchema.extend({ + quote: embeddedStatusSchema, + reblog: embeddedStatusSchema, + pleroma: z.object({ + event: eventSchema, + quote: embeddedStatusSchema, + }), +}).transform(({ pleroma, ...status }) => { + return { + ...status, + event: pleroma.event, + quote: pleroma.quote || status.quote, + }; +}).transform(transformStatus); type Status = z.infer;