diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 1e746e5f710..15f0b9da306 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -538,9 +538,8 @@ class Status extends ImmutablePureComponent { } else if (status.get('card') && !status.get('quote')) { media = ( ); diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx deleted file mode 100644 index 308555b77f6..00000000000 --- a/app/javascript/mastodon/features/status/components/card.jsx +++ /dev/null @@ -1,254 +0,0 @@ -import punycode from 'punycode'; - -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - - -import { is } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; -import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; -import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react'; -import { Blurhash } from 'mastodon/components/blurhash'; -import { Icon } from 'mastodon/components/icon'; -import { MoreFromAuthor } from 'mastodon/components/more_from_author'; -import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; -import { useBlurhash } from 'mastodon/initial_state'; - -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 domParser = new DOMParser(); - -const handleIframeUrl = (html, url, providerName) => { - const document = domParser.parseFromString(html, 'text/html').documentElement; - const iframe = document.querySelector('iframe'); - const startTime = new URL(url).searchParams.get('t') - - if (iframe) { - const iframeUrl = new URL(iframe.src) - - iframeUrl.searchParams.set('autoplay', 1) - iframeUrl.searchParams.set('auto_play', 1) - - if (startTime && providerName === "YouTube") iframeUrl.searchParams.set('start', startTime) - - iframe.src = iframeUrl.href - - // 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 PureComponent { - - static propTypes = { - card: ImmutablePropTypes.map, - onOpenMedia: PropTypes.func.isRequired, - sensitive: PropTypes.bool, - }; - - state = { - previewLoaded: false, - embedded: false, - revealed: !this.props.sensitive, - }; - - UNSAFE_componentWillReceiveProps (nextProps) { - if (!is(this.props.card, nextProps.card)) { - this.setState({ embedded: false, previewLoaded: false }); - } - - if (this.props.sensitive !== nextProps.sensitive) { - this.setState({ revealed: !nextProps.sensitive }); - } - } - - componentDidMount () { - window.addEventListener('resize', this.handleResize, { passive: true }); - } - - componentWillUnmount () { - window.removeEventListener('resize', this.handleResize); - } - - handleEmbedClick = () => { - this.setState({ embedded: true }); - }; - - handleExternalLinkClick = (e) => { - e.stopPropagation(); - }; - - setRef = c => { - this.node = c; - }; - - handleImageLoad = () => { - this.setState({ previewLoaded: true }); - }; - - handleReveal = e => { - e.preventDefault(); - e.stopPropagation(); - this.setState({ revealed: true }); - }; - - renderVideo () { - const { card } = this.props; - const content = { __html: handleIframeUrl(card.get('html'), card.get('url'), card.get('provider_name')) }; - - return ( -
- ); - } - - render () { - const { card } = this.props; - const { embedded, revealed } = 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') === 'video'; - const language = card.get('language') || ''; - const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive; - const showAuthor = !!card.getIn(['authors', 0, 'accountId']); - - const description = ( -
- - {provider} - {card.get('published_at') && <> · } - - - {card.get('title')} - - {!showAuthor && (card.get('author_name').length > 0 ? {card.get('author_name')} }} /> : {card.get('description')})} -
- ); - - const thumbnailStyle = { - visibility: revealed ? null : 'hidden', - }; - - if (largeImage && card.get('type') === 'video') { - thumbnailStyle.aspectRatio = `16 / 9`; - } else if (largeImage) { - thumbnailStyle.aspectRatio = '1.91 / 1'; - } else { - thumbnailStyle.aspectRatio = 1; - } - - let embed; - - let canvas = ( - - ); - - const thumbnailDescription = card.get('image_description'); - const thumbnail = {thumbnailDescription}; - - let spoilerButton = ( - - ); - - spoilerButton = ( -
- {spoilerButton} -
- ); - - if (interactive) { - if (embedded) { - embed = this.renderVideo(); - } else { - embed = ( -
- {canvas} - {thumbnail} - - {revealed ? ( -
-
- - -
-
- ) : spoilerButton} -
- ); - } - - return ( -
- {embed} - {description} -
- ); - } else if (card.get('image')) { - embed = ( -
- {canvas} - {thumbnail} -
- ); - } else { - embed = ( -
- -
- ); - } - - return ( - <> - - {embed} - {description} - - - {showAuthor && } - - ); - } - -} diff --git a/app/javascript/mastodon/features/status/components/card.tsx b/app/javascript/mastodon/features/status/components/card.tsx new file mode 100644 index 00000000000..65826f95f49 --- /dev/null +++ b/app/javascript/mastodon/features/status/components/card.tsx @@ -0,0 +1,316 @@ +import punycode from 'node:punycode'; + +import { useCallback, useId, useState } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; +import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; +import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react'; +import { Blurhash } from 'mastodon/components/blurhash'; +import { Icon } from 'mastodon/components/icon'; +import { MoreFromAuthor } from 'mastodon/components/more_from_author'; +import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; +import { useBlurhash } from 'mastodon/initial_state'; +import type { Card as CardType } from 'mastodon/models/status'; + +const IDNA_PREFIX = 'xn--'; + +const decodeIDNA = (domain: string) => { + return domain + .split('.') + .map((part) => + part.startsWith(IDNA_PREFIX) + ? punycode.decode(part.slice(IDNA_PREFIX.length)) + : part, + ) + .join('.'); +}; + +const getHostname = (url: string) => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +const domParser = new DOMParser(); + +const handleIframeUrl = (html: string, url: string, providerName: string) => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + const iframe = document.querySelector('iframe'); + const startTime = new URL(url).searchParams.get('t'); + + if (iframe) { + const iframeUrl = new URL(iframe.src); + + iframeUrl.searchParams.set('autoplay', '1'); + iframeUrl.searchParams.set('auto_play', '1'); + + if (startTime && providerName === 'YouTube') + iframeUrl.searchParams.set('start', startTime); + + iframe.src = iframeUrl.href; + + // 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; +}; + +interface CardProps { + card: CardType | null; + sensitive?: boolean; +} + +const CardVideo: React.FC> = ({ card }) => ( +
+); + +const Card: React.FC = ({ card, sensitive }) => { + const [previewLoaded, setPreviewLoaded] = useState(false); + const [embedded, setEmbedded] = useState(false); + const [revealed, setRevealed] = useState(!sensitive); + + const handleEmbedClick = useCallback(() => { + setEmbedded(true); + }, []); + + const handleExternalLinkClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); + + const handleImageLoad = useCallback(() => { + setPreviewLoaded(true); + }, []); + + const handleReveal = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setRevealed(true); + }, []); + + const spoilerButtonId = useId(); + + 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') === 'video'; + const language = card.get('language') || ''; + const largeImage = + (card.get('image').length > 0 && card.get('width') > card.get('height')) || + interactive; + const showAuthor = !!card.getIn(['authors', 0, 'accountId']); + + const description = ( +
+ + {provider} + {card.get('published_at') && ( + <> + {' '} + · + + )} + + + + {card.get('title')} + + + {!showAuthor && + (card.get('author_name').length > 0 ? ( + + {card.get('author_name')} }} + /> + + ) : ( + + {card.get('description')} + + ))} +
+ ); + + const thumbnailStyle: React.CSSProperties = { + visibility: revealed ? undefined : 'hidden', + aspectRatio: '1', + }; + + if (largeImage && card.get('type') === 'video') { + thumbnailStyle.aspectRatio = `16 / 9`; + } else if (largeImage) { + thumbnailStyle.aspectRatio = '1.91 / 1'; + } + + let embed; + + const canvas = ( + + ); + + const thumbnailDescription = card.get('image_description'); + const thumbnail = ( + {thumbnailDescription} + ); + + const spoilerButton = ( +
+ +
+ ); + + if (interactive) { + if (embedded) { + embed = ; + } else { + embed = ( +
+ {canvas} + {thumbnail} + + {revealed ? ( +
+
+ + + + +
+
+ ) : ( + spoilerButton + )} +
+ ); + } + + return ( +
+ {embed} + + {description} + +
+ ); + } else if (card.get('image')) { + embed = ( +
+ {canvas} + {thumbnail} +
+ ); + } else { + embed = ( +
+ +
+ ); + } + + return ( + <> + + {embed} + {description} + + + {showAuthor && ( + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Card; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx index b0c66b93384..1dee2e51477 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.tsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx @@ -262,8 +262,8 @@ export const DetailedStatus: React.FC<{ } else if (status.get('card') && !status.get('quote')) { media = ( );