diff --git a/app/soapbox/features/status/components/card.js b/app/soapbox/features/status/components/card.js
deleted file mode 100644
index f556ae3c8..000000000
--- a/app/soapbox/features/status/components/card.js
+++ /dev/null
@@ -1,269 +0,0 @@
-import punycode from 'punycode';
-
-import classnames from 'classnames';
-import { is, fromJS } from 'immutable';
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-
-import Icon from 'soapbox/components/icon';
-import { HStack } from 'soapbox/components/ui';
-
-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 trim = (text, len) => {
- const cut = text.indexOf(' ', len);
-
- if (cut === -1) {
- return text;
- }
-
- return text.substring(0, cut) + (text.length > len ? '…' : '');
-};
-
-const domParser = new DOMParser();
-
-const addAutoPlay = html => {
- const document = domParser.parseFromString(html, 'text/html').documentElement;
- const iframe = document.querySelector('iframe');
-
- if (iframe) {
- if (iframe.src.indexOf('?') !== -1) {
- iframe.src += '&';
- } else {
- iframe.src += '?';
- }
-
- iframe.src += 'autoplay=1&auto_play=1';
- iframe.allow = 'autoplay';
-
- // 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 React.PureComponent {
-
- static propTypes = {
- card: ImmutablePropTypes.record,
- maxDescription: PropTypes.number,
- onOpenMedia: PropTypes.func.isRequired,
- compact: PropTypes.bool,
- defaultWidth: PropTypes.number,
- cacheWidth: PropTypes.func,
- };
-
- static defaultProps = {
- maxDescription: 200,
- compact: false,
- };
-
- state = {
- width: this.props.defaultWidth || 467,
- embedded: false,
- };
-
- componentDidUpdate(prevProps) {
- if (!is(prevProps.card, this.props.card)) {
- this.setState({ embedded: false });
- }
- }
-
- handlePhotoClick = () => {
- const { card, onOpenMedia } = this.props;
-
- onOpenMedia(
- fromJS([
- {
- type: 'image',
- url: card.get('embed_url'),
- description: card.get('title'),
- meta: {
- original: {
- width: card.get('width'),
- height: card.get('height'),
- },
- },
- },
- ]),
- 0,
- );
- };
-
- handleEmbedClick = (e) => {
- const { card } = this.props;
-
- e.stopPropagation();
-
- if (card.get('type') === 'photo') {
- this.handlePhotoClick();
- } else {
- this.setState({ embedded: true });
- }
- }
-
- setRef = c => {
- if (c) {
- if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
- this.setState({ width: c.offsetWidth });
- }
- }
-
- renderVideo() {
- const { card } = this.props;
- const html = card.get('html', card.getIn(['pleroma', 'opengraph', 'html']));
- const content = { __html: addAutoPlay(html) };
- const { width } = this.state;
- const ratio = this.getRatio(card);
- const height = width / ratio;
-
- return (
-
- );
- }
-
- getRatio = card => {
- const width = card.get('width', card.getIn(['pleroma', 'opengraph', 'width']));
- const height = card.get('height', card.getIn(['pleroma', 'opengraph', 'height']));
- const ratio = width / height;
-
- // Invalid dimensions, fall back to 16:9
- if (typeof width !== 'number' || typeof height !== 'number') {
- return 16 / 9;
- }
-
- // Constrain to a sane limit
- // https://en.wikipedia.org/wiki/Aspect_ratio_(image)
- return Math.min(Math.max(9 / 16, ratio), 4);
- }
-
- render() {
- const { card, maxDescription, compact } = this.props;
- const { width, embedded } = 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') !== 'link';
- const horizontal = interactive || embedded;
- const className = classnames('status-card', { horizontal, compact, interactive }, `status-card--${card.get('type')}`);
- const title = interactive ? e.stopPropagation()} className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'>{card.get('title')} : {card.get('title')};
- const ratio = this.getRatio(card);
- const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
-
- const description = (
-
-
{title}
-
{trim(card.get('description') || '', maxDescription)}
-
{provider}
-
- );
-
- let embed = '';
- const imageUrl = card.get('image') || card.getIn(['pleroma', 'opengraph', 'thumbnail_url']);
- const thumbnail = ;
-
- if (interactive) {
- if (embedded) {
- embed = this.renderVideo();
- } else {
- let iconVariant = require('@tabler/icons/icons/player-play.svg');
-
- if (card.get('type') === 'photo') {
- iconVariant = require('@tabler/icons/icons/zoom-in.svg');
- }
-
- embed = (
-
- );
- }
-
- return (
-
- {embed}
- {description}
-
- );
- } else if (card.get('image')) {
- embed = (
-
- {thumbnail}
-
- );
- } else {
- embed = (
-
-
-
- );
- }
-
- return (
- e.stopPropagation()}
- >
- {embed}
- {description}
-
- );
- }
-
-}
diff --git a/app/soapbox/features/status/components/card.tsx b/app/soapbox/features/status/components/card.tsx
new file mode 100644
index 000000000..ea09afe57
--- /dev/null
+++ b/app/soapbox/features/status/components/card.tsx
@@ -0,0 +1,255 @@
+import classnames from 'classnames';
+import { List as ImmutableList } from 'immutable';
+import React, { useState, useEffect } from 'react';
+
+import Icon from 'soapbox/components/icon';
+import { HStack } from 'soapbox/components/ui';
+import { normalizeAttachment } from 'soapbox/normalizers';
+
+import type { Card as CardEntity, Attachment } from 'soapbox/types/entities';
+
+const trim = (text: string, len: number): string => {
+ const cut = text.indexOf(' ', len);
+
+ if (cut === -1) {
+ return text;
+ }
+
+ return text.substring(0, cut) + (text.length > len ? '…' : '');
+};
+
+const domParser = new DOMParser();
+
+const addAutoPlay = (html: string): string => {
+ const document = domParser.parseFromString(html, 'text/html').documentElement;
+ const iframe = document.querySelector('iframe');
+
+ if (iframe) {
+ if (iframe.src.indexOf('?') !== -1) {
+ iframe.src += '&';
+ } else {
+ iframe.src += '?';
+ }
+
+ iframe.src += 'autoplay=1&auto_play=1';
+ iframe.allow = 'autoplay';
+
+ // 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') as HTMLBodyElement).innerHTML;
+ }
+
+ return html;
+};
+
+interface ICard {
+ card: CardEntity,
+ maxTitle?: number,
+ maxDescription?: number,
+ onOpenMedia: (attachments: ImmutableList, index: number) => void,
+ compact?: boolean,
+ defaultWidth?: number,
+ cacheWidth?: (width: number) => void,
+}
+
+const Card: React.FC = ({
+ card,
+ defaultWidth = 467,
+ maxTitle = 120,
+ maxDescription = 200,
+ compact = false,
+ cacheWidth,
+ onOpenMedia,
+}): JSX.Element => {
+ const [width, setWidth] = useState(defaultWidth);
+ const [embedded, setEmbedded] = useState(false);
+
+ useEffect(() => {
+ setEmbedded(false);
+ }, [card.url]);
+
+ const trimmedTitle = trim(card.title, maxTitle);
+ const trimmedDescription = trim(card.description, maxDescription);
+
+ const handlePhotoClick = () => {
+ const attachment = normalizeAttachment({
+ type: 'image',
+ url: card.embed_url,
+ description: trimmedTitle,
+ meta: {
+ original: {
+ width: card.width,
+ height: card.height,
+ },
+ },
+ });
+
+ onOpenMedia(ImmutableList([attachment]), 0);
+ };
+
+ const handleEmbedClick: React.MouseEventHandler = (e) => {
+ e.stopPropagation();
+
+ if (card.type === 'photo') {
+ handlePhotoClick();
+ } else {
+ setEmbedded(true);
+ }
+ };
+
+ const setRef: React.RefCallback = c => {
+ if (c) {
+ if (cacheWidth) {
+ cacheWidth(c.offsetWidth);
+ }
+
+ setWidth(c.offsetWidth);
+ }
+ };
+
+ const renderVideo = () => {
+ const content = { __html: addAutoPlay(card.html) };
+ const ratio = getRatio(card);
+ const height = width / ratio;
+
+ return (
+
+ );
+ };
+
+ const getRatio = (card: CardEntity): number => {
+ const ratio = (card.width / card.height) || 16 / 9;
+
+ // Constrain to a sane limit
+ // https://en.wikipedia.org/wiki/Aspect_ratio_(image)
+ return Math.min(Math.max(9 / 16, ratio), 4);
+ };
+
+ const interactive = card.type !== 'link';
+ const horizontal = interactive || embedded;
+ const className = classnames('status-card', { horizontal, compact, interactive }, `status-card--${card.type}`);
+ const ratio = getRatio(card);
+ const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+
+ const title = interactive ? (
+ e.stopPropagation()}
+ className='status-card__title'
+ href={card.url}
+ title={trimmedTitle}
+ rel='noopener'
+ target='_blank'
+ >
+ {trimmedTitle}
+
+ ) : (
+ {trimmedTitle}
+ );
+
+ const description = (
+
+
{title}
+
{trimmedDescription}
+
{card.provider_name}
+
+ );
+
+ let embed: React.ReactNode = '';
+
+ const thumbnail = (
+
+ );
+
+ if (interactive) {
+ if (embedded) {
+ embed = renderVideo();
+ } else {
+ let iconVariant = require('@tabler/icons/icons/player-play.svg');
+
+ if (card.type === 'photo') {
+ iconVariant = require('@tabler/icons/icons/zoom-in.svg');
+ }
+
+ embed = (
+
+ );
+ }
+
+ return (
+
+ {embed}
+ {description}
+
+ );
+ } else if (card.image) {
+ embed = (
+
+ {thumbnail}
+
+ );
+ } else {
+ embed = (
+
+
+
+ );
+ }
+
+ return (
+ e.stopPropagation()}
+ >
+ {embed}
+ {description}
+
+ );
+};
+
+export default Card;
diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx
index d1560c125..0095f0822 100644
--- a/app/soapbox/features/status/components/detailed-status.tsx
+++ b/app/soapbox/features/status/components/detailed-status.tsx
@@ -186,7 +186,7 @@ class DetailedStatus extends ImmutablePureComponent
);
}
- } else if (status.spoiler_text.length === 0 && !status.quote) {
+ } else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
media = ;
}
diff --git a/app/soapbox/normalizers/card.ts b/app/soapbox/normalizers/card.ts
index 169492647..f222dc8f9 100644
--- a/app/soapbox/normalizers/card.ts
+++ b/app/soapbox/normalizers/card.ts
@@ -3,18 +3,20 @@
* Converts API cards into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/card/}
*/
+import punycode from 'punycode';
+
import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable';
// https://docs.joinmastodon.org/entities/card/
export const CardRecord = ImmutableRecord({
author_name: '',
author_url: '',
- blurhash: null,
+ blurhash: null as string | null,
description: '',
embed_url: '',
height: 0,
html: '',
- image: null,
+ image: null as string | null,
provider_name: '',
provider_url: '',
title: '',
@@ -23,8 +25,59 @@ export const CardRecord = ImmutableRecord({
width: 0,
});
+const IDNA_PREFIX = 'xn--';
+
+const decodeIDNA = (domain: string): string => {
+ return domain
+ .split('.')
+ .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
+ .join('.');
+};
+
+const getHostname = (url: string): string => {
+ const parser = document.createElement('a');
+ parser.href = url;
+ return parser.hostname;
+};
+
+/** Fall back to Pleroma's OG data */
+const normalizeWidth = (card: ImmutableMap) => {
+ const width = card.get('width') || card.getIn(['pleroma', 'opengraph', 'width']) || 0;
+ return card.set('width', width);
+};
+
+/** Fall back to Pleroma's OG data */
+const normalizeHeight = (card: ImmutableMap) => {
+ const height = card.get('height') || card.getIn(['pleroma', 'opengraph', 'height']) || 0;
+ return card.set('height', height);
+};
+
+/** Fall back to Pleroma's OG data */
+const normalizeHtml = (card: ImmutableMap) => {
+ const html = card.get('html') || card.getIn(['pleroma', 'opengraph', 'html']) || '';
+ return card.set('html', html);
+};
+
+/** Fall back to Pleroma's OG data */
+const normalizeImage = (card: ImmutableMap) => {
+ const image = card.get('image') || card.getIn(['pleroma', 'opengraph', 'thumbnail_url']) || null;
+ return card.set('image', image);
+};
+
+/** Set provider from URL if not found */
+const normalizeProviderName = (card: ImmutableMap) => {
+ const providerName = card.get('provider_name') || decodeIDNA(getHostname(card.get('url')));
+ return card.set('provider_name', providerName);
+};
+
export const normalizeCard = (card: Record) => {
return CardRecord(
- ImmutableMap(fromJS(card)),
+ ImmutableMap(fromJS(card)).withMutations(card => {
+ normalizeWidth(card);
+ normalizeHeight(card);
+ normalizeHtml(card);
+ normalizeImage(card);
+ normalizeProviderName(card);
+ }),
);
};