diff --git a/app/soapbox/features/status/components/card.js b/app/soapbox/features/status/components/card.js deleted file mode 100644 index f556ae3c80..0000000000 Binary files a/app/soapbox/features/status/components/card.js and /dev/null differ diff --git a/app/soapbox/features/status/components/card.tsx b/app/soapbox/features/status/components/card.tsx new file mode 100644 index 0000000000..ea09afe572 --- /dev/null +++ b/app/soapbox/features/status/components/card.tsx @@ -0,0 +1,255 @@ +import classnames from 'classnames'; +import { List as ImmutableList } from 'immutable'; +import React, { useState, useEffect } from 'react'; + +import Icon from 'soapbox/components/icon'; +import { HStack } from 'soapbox/components/ui'; +import { normalizeAttachment } from 'soapbox/normalizers'; + +import type { Card as CardEntity, Attachment } from 'soapbox/types/entities'; + +const trim = (text: string, len: number): string => { + const cut = text.indexOf(' ', len); + + if (cut === -1) { + return text; + } + + return text.substring(0, cut) + (text.length > len ? '…' : ''); +}; + +const domParser = new DOMParser(); + +const addAutoPlay = (html: string): string => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + const iframe = document.querySelector('iframe'); + + if (iframe) { + if (iframe.src.indexOf('?') !== -1) { + iframe.src += '&'; + } else { + iframe.src += '?'; + } + + iframe.src += 'autoplay=1&auto_play=1'; + iframe.allow = 'autoplay'; + + // DOM parser creates html/body elements around original HTML fragment, + // so we need to get innerHTML out of the body and not the entire document + return (document.querySelector('body') as HTMLBodyElement).innerHTML; + } + + return html; +}; + +interface ICard { + card: CardEntity, + maxTitle?: number, + maxDescription?: number, + onOpenMedia: (attachments: ImmutableList, index: number) => void, + compact?: boolean, + defaultWidth?: number, + cacheWidth?: (width: number) => void, +} + +const Card: React.FC = ({ + card, + defaultWidth = 467, + maxTitle = 120, + maxDescription = 200, + compact = false, + cacheWidth, + onOpenMedia, +}): JSX.Element => { + const [width, setWidth] = useState(defaultWidth); + const [embedded, setEmbedded] = useState(false); + + useEffect(() => { + setEmbedded(false); + }, [card.url]); + + const trimmedTitle = trim(card.title, maxTitle); + const trimmedDescription = trim(card.description, maxDescription); + + const handlePhotoClick = () => { + const attachment = normalizeAttachment({ + type: 'image', + url: card.embed_url, + description: trimmedTitle, + meta: { + original: { + width: card.width, + height: card.height, + }, + }, + }); + + onOpenMedia(ImmutableList([attachment]), 0); + }; + + const handleEmbedClick: React.MouseEventHandler = (e) => { + e.stopPropagation(); + + if (card.type === 'photo') { + handlePhotoClick(); + } else { + setEmbedded(true); + } + }; + + const setRef: React.RefCallback = c => { + if (c) { + if (cacheWidth) { + cacheWidth(c.offsetWidth); + } + + setWidth(c.offsetWidth); + } + }; + + const renderVideo = () => { + const content = { __html: addAutoPlay(card.html) }; + const ratio = getRatio(card); + const height = width / ratio; + + return ( +
+ ); + }; + + const getRatio = (card: CardEntity): number => { + const ratio = (card.width / card.height) || 16 / 9; + + // Constrain to a sane limit + // https://en.wikipedia.org/wiki/Aspect_ratio_(image) + return Math.min(Math.max(9 / 16, ratio), 4); + }; + + const interactive = card.type !== 'link'; + const horizontal = interactive || embedded; + const className = classnames('status-card', { horizontal, compact, interactive }, `status-card--${card.type}`); + const ratio = getRatio(card); + const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); + + const title = interactive ? ( + e.stopPropagation()} + className='status-card__title' + href={card.url} + title={trimmedTitle} + rel='noopener' + target='_blank' + > + {trimmedTitle} + + ) : ( + {trimmedTitle} + ); + + const description = ( +
+ {title} +

{trimmedDescription}

+ {card.provider_name} +
+ ); + + let embed: React.ReactNode = ''; + + const thumbnail = ( +
+ ); + + if (interactive) { + if (embedded) { + embed = renderVideo(); + } else { + let iconVariant = require('@tabler/icons/icons/player-play.svg'); + + if (card.type === 'photo') { + iconVariant = require('@tabler/icons/icons/zoom-in.svg'); + } + + embed = ( + + ); + } + + return ( +
+ {embed} + {description} +
+ ); + } else if (card.image) { + embed = ( +
+ {thumbnail} +
+ ); + } else { + embed = ( +
+ +
+ ); + } + + return ( + e.stopPropagation()} + > + {embed} + {description} + + ); +}; + +export default Card; diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index d1560c125d..0095f08223 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -186,7 +186,7 @@ class DetailedStatus extends ImmutablePureComponent ); } - } else if (status.spoiler_text.length === 0 && !status.quote) { + } else if (status.spoiler_text.length === 0 && !status.quote && status.card) { media = ; } diff --git a/app/soapbox/normalizers/card.ts b/app/soapbox/normalizers/card.ts index 1694926471..f222dc8f9e 100644 --- a/app/soapbox/normalizers/card.ts +++ b/app/soapbox/normalizers/card.ts @@ -3,18 +3,20 @@ * Converts API cards into our internal format. * @see {@link https://docs.joinmastodon.org/entities/card/} */ +import punycode from 'punycode'; + import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable'; // https://docs.joinmastodon.org/entities/card/ export const CardRecord = ImmutableRecord({ author_name: '', author_url: '', - blurhash: null, + blurhash: null as string | null, description: '', embed_url: '', height: 0, html: '', - image: null, + image: null as string | null, provider_name: '', provider_url: '', title: '', @@ -23,8 +25,59 @@ export const CardRecord = ImmutableRecord({ 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 normalizeWidth = (card: ImmutableMap) => { + const width = card.get('width') || card.getIn(['pleroma', 'opengraph', 'width']) || 0; + return card.set('width', width); +}; + +/** Fall back to Pleroma's OG data */ +const normalizeHeight = (card: ImmutableMap) => { + const height = card.get('height') || card.getIn(['pleroma', 'opengraph', 'height']) || 0; + return card.set('height', height); +}; + +/** Fall back to Pleroma's OG data */ +const normalizeHtml = (card: ImmutableMap) => { + const html = card.get('html') || card.getIn(['pleroma', 'opengraph', 'html']) || ''; + return card.set('html', html); +}; + +/** Fall back to Pleroma's OG data */ +const normalizeImage = (card: ImmutableMap) => { + const image = card.get('image') || card.getIn(['pleroma', 'opengraph', 'thumbnail_url']) || null; + return card.set('image', image); +}; + +/** 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); +}; + export const normalizeCard = (card: Record) => { return CardRecord( - ImmutableMap(fromJS(card)), + ImmutableMap(fromJS(card)).withMutations(card => { + normalizeWidth(card); + normalizeHeight(card); + normalizeHtml(card); + normalizeImage(card); + normalizeProviderName(card); + }), ); };