Refactor Card component to TypeScript (#36982)

This commit is contained in:
diondiondion 2025-11-26 13:56:17 +01:00 committed by GitHub
parent 1757a0f0f3
commit f87f30c1ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 318 additions and 257 deletions

View File

@ -538,9 +538,8 @@ class Status extends ImmutablePureComponent {
} else if (status.get('card') && !status.get('quote')) { } else if (status.get('card') && !status.get('quote')) {
media = ( media = (
<Card <Card
onOpenMedia={this.handleOpenMedia} key={`${status.get('id')}-${status.get('edited_at')}`}
card={status.get('card')} card={status.get('card')}
compact
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
/> />
); );

View File

@ -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 (
<div
ref={this.setRef}
className='status-card__image status-card-video'
dangerouslySetInnerHTML={content}
style={{ aspectRatio: '16 / 9' }}
/>
);
}
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 = (
<div className='status-card__content' dir='auto'>
<span className='status-card__host'>
<span lang={language}>{provider}</span>
{card.get('published_at') && <> · <RelativeTimestamp timestamp={card.get('published_at')} /></>}
</span>
<strong className='status-card__title' title={card.get('title')} lang={language}>{card.get('title')}</strong>
{!showAuthor && (card.get('author_name').length > 0 ? <span className='status-card__author'><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{card.get('author_name')}</strong> }} /></span> : <span className='status-card__description' lang={language}>{card.get('description')}</span>)}
</div>
);
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 = (
<Blurhash
className={classNames('status-card__image-preview', {
'status-card__image-preview--hidden': revealed && this.state.previewLoaded,
})}
hash={card.get('blurhash')}
dummy={!useBlurhash}
/>
);
const thumbnailDescription = card.get('image_description');
const thumbnail = <img src={card.get('image')} alt={thumbnailDescription} title={thumbnailDescription} lang={language} style={thumbnailStyle} onLoad={this.handleImageLoad} className='status-card__image-image' />;
let spoilerButton = (
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
<FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
);
spoilerButton = (
<div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
{spoilerButton}
</div>
);
if (interactive) {
if (embedded) {
embed = this.renderVideo();
} else {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
{revealed ? (
<div className='status-card__actions' onClick={this.handleEmbedClick} role='none'>
<div>
<button type='button' onClick={this.handleEmbedClick}><Icon id='play' icon={PlayArrowIcon} /></button>
<a href={card.get('url')} onClick={this.handleExternalLinkClick} target='_blank' rel='noopener'><Icon id='external-link' icon={OpenInNewIcon} /></a>
</div>
</div>
) : spoilerButton}
</div>
);
}
return (
<div className={classNames('status-card', { expanded: largeImage })} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
{embed}
<a href={card.get('url')} target='_blank' rel='noopener'>{description}</a>
</div>
);
} else if (card.get('image')) {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
</div>
);
} else {
embed = (
<div className='status-card__image'>
<Icon id='file-text' icon={DescriptionIcon} />
</div>
);
}
return (
<>
<a href={card.get('url')} className={classNames('status-card', { expanded: largeImage, bottomless: showAuthor })} target='_blank' rel='noopener' ref={this.setRef}>
{embed}
{description}
</a>
{showAuthor && <MoreFromAuthor accountId={card.getIn(['authors', 0, 'accountId'])} />}
</>
);
}
}

View File

@ -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<Pick<CardProps, 'card'>> = ({ card }) => (
<div
className='status-card__image status-card-video'
dangerouslySetInnerHTML={{
__html: card
? handleIframeUrl(
card.get('html'),
card.get('url'),
card.get('provider_name'),
)
: '',
}}
style={{ aspectRatio: '16 / 9' }}
/>
);
const Card: React.FC<CardProps> = ({ 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 = (
<div className='status-card__content' dir='auto'>
<span className='status-card__host'>
<span lang={language}>{provider}</span>
{card.get('published_at') && (
<>
{' '}
· <RelativeTimestamp timestamp={card.get('published_at')} />
</>
)}
</span>
<strong
className='status-card__title'
title={card.get('title')}
lang={language}
>
{card.get('title')}
</strong>
{!showAuthor &&
(card.get('author_name').length > 0 ? (
<span className='status-card__author'>
<FormattedMessage
id='link_preview.author'
defaultMessage='By {name}'
values={{ name: <strong>{card.get('author_name')}</strong> }}
/>
</span>
) : (
<span className='status-card__description' lang={language}>
{card.get('description')}
</span>
))}
</div>
);
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 = (
<Blurhash
className={classNames('status-card__image-preview', {
'status-card__image-preview--hidden': revealed && previewLoaded,
})}
hash={card.get('blurhash')}
dummy={!useBlurhash}
/>
);
const thumbnailDescription = card.get('image_description');
const thumbnail = (
<img
src={card.get('image')}
alt={thumbnailDescription}
title={thumbnailDescription}
lang={language}
style={thumbnailStyle}
onLoad={handleImageLoad}
className='status-card__image-image'
/>
);
const spoilerButton = (
<div
className={classNames('spoiler-button', {
'spoiler-button--minified': revealed,
})}
id={spoilerButtonId}
>
<button
type='button'
onClick={handleReveal}
className='spoiler-button__overlay'
>
<span className='spoiler-button__overlay__label'>
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
<span className='spoiler-button__overlay__action'>
<FormattedMessage
id='status.media.show'
defaultMessage='Click to show'
/>
</span>
</span>
</button>
</div>
);
if (interactive) {
if (embedded) {
embed = <CardVideo card={card} />;
} else {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
{revealed ? (
<div
className='status-card__actions'
onClick={handleEmbedClick}
role='none'
>
<div>
<button type='button' onClick={handleEmbedClick}>
<Icon id='play' icon={PlayArrowIcon} />
</button>
<a
href={card.get('url')}
onClick={handleExternalLinkClick}
target='_blank'
rel='noopener'
>
<Icon id='external-link' icon={OpenInNewIcon} />
</a>
</div>
</div>
) : (
spoilerButton
)}
</div>
);
}
return (
<div className={classNames('status-card', { expanded: largeImage })}>
{embed}
<a
href={card.get('url')}
target='_blank'
rel='noopener'
onClick={revealed ? undefined : handleReveal}
aria-describedby={revealed ? undefined : spoilerButtonId}
>
{description}
</a>
</div>
);
} else if (card.get('image')) {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
</div>
);
} else {
embed = (
<div className='status-card__image'>
<Icon id='file-text' icon={DescriptionIcon} />
</div>
);
}
return (
<>
<a
href={card.get('url')}
className={classNames('status-card', {
expanded: largeImage,
bottomless: showAuthor,
})}
target='_blank'
rel='noopener'
>
{embed}
{description}
</a>
{showAuthor && (
<MoreFromAuthor
accountId={card.getIn(['authors', 0, 'accountId']) as string}
/>
)}
</>
);
};
// eslint-disable-next-line import/no-default-export
export default Card;

View File

@ -262,8 +262,8 @@ export const DetailedStatus: React.FC<{
} else if (status.get('card') && !status.get('quote')) { } else if (status.get('card') && !status.get('quote')) {
media = ( media = (
<Card <Card
key={`${status.get('id')}-${status.get('edited_at')}`}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenMedia={onOpenMedia}
card={status.get('card')} card={status.get('card')}
/> />
); );