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 =
;
-
- let spoilerButton = (
-
- );
-
- spoilerButton = (
-
- {spoilerButton}
-
- );
-
- if (interactive) {
- if (embedded) {
- embed = this.renderVideo();
- } else {
- embed = (
-
- {canvas}
- {thumbnail}
-
- {revealed ? (
-
- ) : spoilerButton}
-
- );
- }
-
- return (
-
- );
- } 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 = (
+
+ );
+
+ 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;
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 = (
);