diff --git a/app/soapbox/normalizers/card.ts b/app/soapbox/normalizers/card.ts index 5d0af42cfd..c29a0c40b3 100644 --- a/app/soapbox/normalizers/card.ts +++ b/app/soapbox/normalizers/card.ts @@ -1,82 +1,25 @@ -/** - * Card normalizer: - * Converts API cards into our internal format. - * @see {@link https://docs.joinmastodon.org/entities/card/} - */ -import punycode from 'punycode'; +import { cardSchema, type Card } from 'soapbox/schemas/card'; -import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable'; - -import { groupSchema, type Group } from 'soapbox/schemas'; -import { mergeDefined } from 'soapbox/utils/normalizers'; - -// https://docs.joinmastodon.org/entities/card/ -export const CardRecord = ImmutableRecord({ - author_name: '', - author_url: '', - blurhash: null as string | null, - description: '', - embed_url: '', - group: null as null | Group, - height: 0, - html: '', - image: null as string | null, - provider_name: '', - provider_url: '', - title: '', - type: 'link', - url: '', - width: 0, -}); - -const IDNA_PREFIX = 'xn--'; - -const decodeIDNA = (domain: string): string => { - return domain - .split('.') - .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) - .join('.'); -}; - -const getHostname = (url: string): string => { - const parser = document.createElement('a'); - parser.href = url; - return parser.hostname; -}; - -/** Fall back to Pleroma's OG data */ -const normalizePleromaOpengraph = (card: ImmutableMap) => { - const opengraph = ImmutableMap({ - width: card.getIn(['pleroma', 'opengraph', 'width']), - height: card.getIn(['pleroma', 'opengraph', 'height']), - html: card.getIn(['pleroma', 'opengraph', 'html']), - image: card.getIn(['pleroma', 'opengraph', 'thumbnail_url']), - }); - - return card.mergeWith(mergeDefined, opengraph); -}; - -/** Set provider from URL if not found */ -const normalizeProviderName = (card: ImmutableMap) => { - const providerName = card.get('provider_name') || decodeIDNA(getHostname(card.get('url'))); - return card.set('provider_name', providerName); -}; - -const normalizeGroup = (card: ImmutableMap) => { +export const normalizeCard = (card: unknown): Card => { try { - const group = groupSchema.parse(card.get('group').toJS()); - return card.set('group', group); + return cardSchema.parse(card); } catch (_e) { - return card.set('group', null); + return { + author_name: '', + author_url: '', + blurhash: null, + description: '', + embed_url: '', + group: null, + height: 0, + html: '', + image: null, + provider_name: '', + provider_url: '', + title: '', + type: 'link', + url: '', + width: 0, + }; } }; - -export const normalizeCard = (card: Record) => { - return CardRecord( - ImmutableMap(fromJS(card)).withMutations(card => { - normalizePleromaOpengraph(card); - normalizeProviderName(card); - normalizeGroup(card); - }), - ); -}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 0040499887..7f1d76a01e 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -4,7 +4,7 @@ export { AdminReportRecord, normalizeAdminReport } from './admin-report'; export { AnnouncementRecord, normalizeAnnouncement } from './announcement'; export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement-reaction'; export { AttachmentRecord, normalizeAttachment } from './attachment'; -export { CardRecord, normalizeCard } from './card'; +export { normalizeCard } from './card'; export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat-message'; export { EmojiRecord, normalizeEmoji } from './emoji'; diff --git a/app/soapbox/normalizers/soapbox/ad.ts b/app/soapbox/normalizers/soapbox/ad.ts index 85dbcc8c61..10ae96d608 100644 --- a/app/soapbox/normalizers/soapbox/ad.ts +++ b/app/soapbox/normalizers/soapbox/ad.ts @@ -4,12 +4,12 @@ import { fromJS, } from 'immutable'; -import { CardRecord, normalizeCard } from '../card'; +import { normalizeCard } from '../card'; import type { Ad } from 'soapbox/features/ads/providers'; export const AdRecord = ImmutableRecord({ - card: CardRecord(), + card: normalizeCard({}), impression: undefined as string | undefined, expires_at: undefined as string | undefined, reason: undefined as string | undefined, @@ -18,7 +18,7 @@ export const AdRecord = ImmutableRecord({ /** Normalizes an ad from Soapbox Config. */ export const normalizeAd = (ad: Record) => { const map = ImmutableMap(fromJS(ad)); - const card = normalizeCard(map.get('card')); + const card = normalizeCard(map.get('card').toJS()); const expiresAt = map.get('expires_at') || map.get('expires'); return AdRecord(map.merge({ diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 7a71f24a88..64a00b3164 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -11,10 +11,10 @@ import { } from 'immutable'; import { normalizeAttachment } from 'soapbox/normalizers/attachment'; -import { normalizeCard } from 'soapbox/normalizers/card'; import { normalizeEmoji } from 'soapbox/normalizers/emoji'; import { normalizeMention } from 'soapbox/normalizers/mention'; import { normalizePoll } from 'soapbox/normalizers/poll'; +import { cardSchema } from 'soapbox/schemas/card'; import type { ReducerAccount } from 'soapbox/reducers/accounts'; import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities'; @@ -118,9 +118,10 @@ const normalizeStatusPoll = (status: ImmutableMap) => { // Normalize card const normalizeStatusCard = (status: ImmutableMap) => { - if (status.get('card')) { - return status.update('card', ImmutableMap(), normalizeCard); - } else { + try { + const card = cardSchema.parse(status.get('card').toJS()); + return status.set('card', card); + } catch (e) { return status.set('card', null); } }; diff --git a/app/soapbox/schemas/card.ts b/app/soapbox/schemas/card.ts new file mode 100644 index 0000000000..c3826fa1db --- /dev/null +++ b/app/soapbox/schemas/card.ts @@ -0,0 +1,69 @@ +import punycode from 'punycode'; + +import { z } from 'zod'; + +import { groupSchema } from './group'; + +const IDNA_PREFIX = 'xn--'; + +/** + * Card (aka link preview). + * https://docs.joinmastodon.org/entities/card/ + */ +const cardSchema = z.object({ + author_name: z.string().catch(''), + author_url: z.string().url().catch(''), + blurhash: z.string().nullable().catch(null), + description: z.string().catch(''), + embed_url: z.string().url().catch(''), + group: groupSchema.nullable().catch(null), // TruthSocial + height: z.number().catch(0), + html: z.string().catch(''), + image: z.string().nullable().catch(null), + pleroma: z.object({ + opengraph: z.object({ + width: z.number(), + height: z.number(), + html: z.string(), + thumbnail_url: z.string().url(), + }).optional().catch(undefined), + }).optional().catch(undefined), + provider_name: z.string().catch(''), + provider_url: z.string().url().catch(''), + title: z.string().catch(''), + type: z.enum(['link', 'photo', 'video', 'rich']).catch('link'), + url: z.string().url(), + width: z.number().catch(0), +}).transform((card) => { + if (!card.provider_name) { + card.provider_name = decodeIDNA(new URL(card.url).hostname); + } + + if (card.pleroma?.opengraph) { + if (!card.width && !card.height) { + card.width = card.pleroma.opengraph.width; + card.height = card.pleroma.opengraph.height; + } + + if (!card.html) { + card.html = card.pleroma.opengraph.html; + } + + if (!card.image) { + card.image = card.pleroma.opengraph.thumbnail_url; + } + } + + return card; +}); + +const decodeIDNA = (domain: string): string => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; + +type Card = z.infer; + +export { cardSchema, type Card }; \ No newline at end of file diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts index a675b52d26..23f6067c8d 100644 --- a/app/soapbox/schemas/index.ts +++ b/app/soapbox/schemas/index.ts @@ -13,6 +13,7 @@ export { relationshipSchema } from './relationship'; * Entity Types */ export type { Account } from './account'; +export type { Card } from './card'; export type { CustomEmoji } from './custom-emoji'; export type { Group } from './group'; export type { GroupMember } from './group-member'; diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index c4d8907c5b..ebbdd197d4 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -5,7 +5,6 @@ import { AnnouncementRecord, AnnouncementReactionRecord, AttachmentRecord, - CardRecord, ChatRecord, ChatMessageRecord, EmojiRecord, @@ -37,7 +36,6 @@ type AdminReport = ReturnType; type Announcement = ReturnType; type AnnouncementReaction = ReturnType; type Attachment = ReturnType; -type Card = ReturnType; type Chat = ReturnType; type ChatMessage = ReturnType; type Emoji = ReturnType; @@ -82,7 +80,6 @@ export { Announcement, AnnouncementReaction, Attachment, - Card, Chat, ChatMessage, Emoji, @@ -110,6 +107,7 @@ export { }; export type { + Card, Group, GroupMember, GroupRelationship,