import { useCallback, useId, useMemo, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import { isMap } from 'immutable'; import punycode from 'punycode/'; 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 { displayMedia, useBlurhash } from 'mastodon/initial_state'; import type { CardShape, 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 (providerName === 'YouTube') { iframeUrl.searchParams.set('start', startTime ?? ''); iframe.referrerPolicy = 'strict-origin-when-cross-origin'; } 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; }; const hideAllMedia = displayMedia === 'hide_all'; interface CardProps { card: CardType | CardShape | null; sensitive?: boolean; } const CardVideo: React.FC<{ card: CardShape }> = ({ card }) => { const { html, url, provider_name } = card; const iframeContent = useMemo( () => ({ __html: handleIframeUrl(html, url, provider_name), }), [html, provider_name, url], ); return (
); }; const Card: React.FC = ({ card: rawCard, sensitive }) => { const card: CardShape | null = useMemo( () => (isMap(rawCard) ? (rawCard.toJS() as CardShape) : rawCard), [rawCard], ); const [previewLoaded, setPreviewLoaded] = useState(false); const [embedded, setEmbedded] = useState(false); const [revealed, setRevealed] = useState(!sensitive && !hideAllMedia); 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.provider_name.length === 0 ? decodeIDNA(getHostname(card.url)) : card.provider_name; const interactive = card.type === 'video'; const language = card.language ?? ''; const hasImage = (card.image?.length ?? 0) > 0; const largeImage = (hasImage && card.width > card.height) || interactive; const author = card.authors.at(0)?.accountId; const description = (
{provider} {card.published_at && ( <> {' '} ยท )} {card.title} {!author && (card.author_name.length > 0 ? ( {card.author_name} }} /> ) : ( {card.description} ))}
); const thumbnailStyle: React.CSSProperties = { visibility: revealed ? undefined : 'hidden', aspectRatio: '1', }; if (largeImage && card.type === 'video') { thumbnailStyle.aspectRatio = `16 / 9`; } else if (largeImage) { thumbnailStyle.aspectRatio = '1.91 / 1'; } let embed; const canvas = ( ); const thumbnailDescription = card.image_description; const thumbnail = ( {thumbnailDescription} ); const spoilerButton = (
); if (interactive) { if (embedded) { embed = ; } else { embed = (
{canvas} {thumbnail} {revealed ? ( ) : ( spoilerButton )}
); } return (
{embed} {description}
); } else if (card.image) { embed = (
{canvas} {revealed ? undefined : spoilerButton} {thumbnail}
); } else { embed = (
); } return ( <> {embed} {description} {author && } ); }; // eslint-disable-next-line import/no-default-export export default Card;