diff --git a/app/soapbox/features/status/components/card.js b/app/soapbox/features/status/components/card.js deleted file mode 100644 index f556ae3c8..000000000 --- a/app/soapbox/features/status/components/card.js +++ /dev/null @@ -1,269 +0,0 @@ -import punycode from 'punycode'; - -import classnames from 'classnames'; -import { is, fromJS } from 'immutable'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import Icon from 'soapbox/components/icon'; -import { HStack } from 'soapbox/components/ui'; - -const IDNA_PREFIX = 'xn--'; - -const decodeIDNA = domain => { - return domain - .split('.') - .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) - .join('.'); -}; - -const getHostname = url => { - const parser = document.createElement('a'); - parser.href = url; - return parser.hostname; -}; - -const trim = (text, len) => { - 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 => { - 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').innerHTML; - } - - return html; -}; - -export default class Card extends React.PureComponent { - - static propTypes = { - card: ImmutablePropTypes.record, - maxDescription: PropTypes.number, - onOpenMedia: PropTypes.func.isRequired, - compact: PropTypes.bool, - defaultWidth: PropTypes.number, - cacheWidth: PropTypes.func, - }; - - static defaultProps = { - maxDescription: 200, - compact: false, - }; - - state = { - width: this.props.defaultWidth || 467, - embedded: false, - }; - - componentDidUpdate(prevProps) { - if (!is(prevProps.card, this.props.card)) { - this.setState({ embedded: false }); - } - } - - handlePhotoClick = () => { - const { card, onOpenMedia } = this.props; - - onOpenMedia( - fromJS([ - { - type: 'image', - url: card.get('embed_url'), - description: card.get('title'), - meta: { - original: { - width: card.get('width'), - height: card.get('height'), - }, - }, - }, - ]), - 0, - ); - }; - - handleEmbedClick = (e) => { - const { card } = this.props; - - e.stopPropagation(); - - if (card.get('type') === 'photo') { - this.handlePhotoClick(); - } else { - this.setState({ embedded: true }); - } - } - - setRef = c => { - if (c) { - if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth); - this.setState({ width: c.offsetWidth }); - } - } - - renderVideo() { - const { card } = this.props; - const html = card.get('html', card.getIn(['pleroma', 'opengraph', 'html'])); - const content = { __html: addAutoPlay(html) }; - const { width } = this.state; - const ratio = this.getRatio(card); - const height = width / ratio; - - return ( -
- ); - } - - getRatio = card => { - const width = card.get('width', card.getIn(['pleroma', 'opengraph', 'width'])); - const height = card.get('height', card.getIn(['pleroma', 'opengraph', 'height'])); - const ratio = width / height; - - // Invalid dimensions, fall back to 16:9 - if (typeof width !== 'number' || typeof height !== 'number') { - return 16 / 9; - } - - // Constrain to a sane limit - // https://en.wikipedia.org/wiki/Aspect_ratio_(image) - return Math.min(Math.max(9 / 16, ratio), 4); - } - - render() { - const { card, maxDescription, compact } = this.props; - const { width, embedded } = this.state; - - if (card === null) { - return null; - } - - const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name'); - const interactive = card.get('type') !== 'link'; - const horizontal = interactive || embedded; - const className = classnames('status-card', { horizontal, compact, interactive }, `status-card--${card.get('type')}`); - const title = interactive ? e.stopPropagation()} className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'>{card.get('title')} : {card.get('title')}; - const ratio = this.getRatio(card); - const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); - - const description = ( -
- {title} -

{trim(card.get('description') || '', maxDescription)}

- {provider} -
- ); - - let embed = ''; - const imageUrl = card.get('image') || card.getIn(['pleroma', 'opengraph', 'thumbnail_url']); - const thumbnail =
; - - if (interactive) { - if (embedded) { - embed = this.renderVideo(); - } else { - let iconVariant = require('@tabler/icons/icons/player-play.svg'); - - if (card.get('type') === 'photo') { - iconVariant = require('@tabler/icons/icons/zoom-in.svg'); - } - - embed = ( -
- {thumbnail} - -
-
- - - - {horizontal && ( - e.stopPropagation()} - href={card.get('url')} - target='_blank' - rel='noopener' - className='text-gray-400 hover:text-gray-600' - > - - - )} - -
-
-
- ); - } - - return ( -
- {embed} - {description} -
- ); - } else if (card.get('image')) { - embed = ( -
- {thumbnail} -
- ); - } else { - embed = ( -
- -
- ); - } - - return ( - e.stopPropagation()} - > - {embed} - {description} - - ); - } - -} diff --git a/app/soapbox/features/status/components/card.tsx b/app/soapbox/features/status/components/card.tsx new file mode 100644 index 000000000..ea09afe57 --- /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 d1560c125..0095f0822 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 169492647..f222dc8f9 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); + }), ); };