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 hasImage = (card.get('image')?.length ?? 0) > 0; const largeImage = (hasImage && 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 ( ); } 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;