Merge branch 'next-status-clickery' into 'next'
Next: Convert Status Card to TSX+FC, refactor See merge request soapbox-pub/soapbox-fe!1229
This commit is contained in:
commit
bfd6891f1b
|
@ -87,6 +87,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
|
||||||
const onMentionClick = (mention: Mention, e: MouseEvent) => {
|
const onMentionClick = (mention: Mention, e: MouseEvent) => {
|
||||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
history.push(`/@${mention.acct}`);
|
history.push(`/@${mention.acct}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -96,10 +97,16 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
|
||||||
|
|
||||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
history.push(`/tags/${hashtag}`);
|
history.push(`/tags/${hashtag}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** For regular links, just stop propogation */
|
||||||
|
const onLinkClick = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
const updateStatusLinks = () => {
|
const updateStatusLinks = () => {
|
||||||
if (!node.current) return;
|
if (!node.current) return;
|
||||||
|
|
||||||
|
@ -124,6 +131,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
|
||||||
link.addEventListener('click', onHashtagClick.bind(link, link.text), false);
|
link.addEventListener('click', onHashtagClick.bind(link, link.text), false);
|
||||||
} else {
|
} else {
|
||||||
link.setAttribute('title', link.href);
|
link.setAttribute('title', link.href);
|
||||||
|
link.addEventListener('click', onLinkClick.bind(link), false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,262 +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 (
|
|
||||||
<div
|
|
||||||
ref={this.setRef}
|
|
||||||
className='status-card__image status-card-video'
|
|
||||||
dangerouslySetInnerHTML={content}
|
|
||||||
style={{ height }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ? <a onClick={(e) => e.stopPropagation()} className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
|
|
||||||
const ratio = this.getRatio(card);
|
|
||||||
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
|
||||||
|
|
||||||
const description = (
|
|
||||||
<div className='status-card__content cursor-default'>
|
|
||||||
<span className='status-card__title'>{title}</span>
|
|
||||||
<p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>
|
|
||||||
<span className='status-card__host'><Icon src={require('@tabler/icons/icons/link.svg')} /> {provider}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
let embed = '';
|
|
||||||
const imageUrl = card.get('image') || card.getIn(['pleroma', 'opengraph', 'thumbnail_url']);
|
|
||||||
const thumbnail = <div style={{ backgroundImage: `url(${imageUrl})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />;
|
|
||||||
|
|
||||||
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 = (
|
|
||||||
<div className='status-card__image'>
|
|
||||||
{thumbnail}
|
|
||||||
|
|
||||||
<div className='absolute inset-0 flex items-center justify-center'>
|
|
||||||
<div className='bg-white shadow-md rounded-md p-2 flex items-center justify-center'>
|
|
||||||
<HStack space={3} alignItems='center'>
|
|
||||||
<button onClick={this.handleEmbedClick} className='appearance-none text-gray-400 hover:text-gray-600'>
|
|
||||||
<Icon
|
|
||||||
src={iconVariant}
|
|
||||||
className='w-5 h-5 text-inherit'
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{horizontal && (
|
|
||||||
<a
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
href={card.get('url')}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener'
|
|
||||||
className='text-gray-400 hover:text-gray-600'
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/icons/external-link.svg')}
|
|
||||||
className='w-5 h-5 text-inherit'
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} ref={this.setRef}>
|
|
||||||
{embed}
|
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (card.get('image')) {
|
|
||||||
embed = (
|
|
||||||
<div className='status-card__image'>
|
|
||||||
{thumbnail}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
embed = (
|
|
||||||
<div className='status-card__image status-card__image--empty'>
|
|
||||||
<Icon src={require('@tabler/icons/icons/file-text.svg')} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href={card.get('url')} className={className} target='_blank' rel='noopener' ref={this.setRef}>
|
|
||||||
{embed}
|
|
||||||
{description}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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<Attachment>, index: number) => void,
|
||||||
|
compact?: boolean,
|
||||||
|
defaultWidth?: number,
|
||||||
|
cacheWidth?: (width: number) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card: React.FC<ICard> = ({
|
||||||
|
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<HTMLElement> = 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 (
|
||||||
|
<div
|
||||||
|
ref={setRef}
|
||||||
|
className='status-card__image status-card-video'
|
||||||
|
dangerouslySetInnerHTML={content}
|
||||||
|
style={{ height }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<a
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className='status-card__title'
|
||||||
|
href={card.url}
|
||||||
|
title={trimmedTitle}
|
||||||
|
rel='noopener'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
<strong>{trimmedTitle}</strong>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<strong className='status-card__title' title={trimmedTitle}>{trimmedTitle}</strong>
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = (
|
||||||
|
<div className='status-card__content cursor-default'>
|
||||||
|
<span className='status-card__title'>{title}</span>
|
||||||
|
<p className='status-card__description'>{trimmedDescription}</p>
|
||||||
|
<span className='status-card__host'><Icon src={require('@tabler/icons/icons/link.svg')} /> {card.provider_name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
let embed: React.ReactNode = '';
|
||||||
|
|
||||||
|
const thumbnail = (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${card.image})`,
|
||||||
|
width: horizontal ? width : undefined,
|
||||||
|
height: horizontal ? height : undefined,
|
||||||
|
}}
|
||||||
|
className='status-card__image-image'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<div className='status-card__image'>
|
||||||
|
{thumbnail}
|
||||||
|
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<div className='bg-white shadow-md rounded-md p-2 flex items-center justify-center'>
|
||||||
|
<HStack space={3} alignItems='center'>
|
||||||
|
<button onClick={handleEmbedClick} className='appearance-none text-gray-400 hover:text-gray-600'>
|
||||||
|
<Icon
|
||||||
|
src={iconVariant}
|
||||||
|
className='w-5 h-5 text-inherit'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{horizontal && (
|
||||||
|
<a
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
href={card.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener'
|
||||||
|
className='text-gray-400 hover:text-gray-600'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/icons/external-link.svg')}
|
||||||
|
className='w-5 h-5 text-inherit'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} ref={setRef}>
|
||||||
|
{embed}
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (card.image) {
|
||||||
|
embed = (
|
||||||
|
<div className='status-card__image'>
|
||||||
|
{thumbnail}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
embed = (
|
||||||
|
<div className='status-card__image status-card__image--empty'>
|
||||||
|
<Icon src={require('@tabler/icons/icons/file-text.svg')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={card.url}
|
||||||
|
className={className}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener'
|
||||||
|
ref={setRef}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{embed}
|
||||||
|
{description}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
|
@ -186,7 +186,7 @@ class DetailedStatus extends ImmutablePureComponent<IDetailedStatus, IDetailedSt
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (status.spoiler_text.length === 0 && !status.quote) {
|
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
||||||
media = <Card onOpenMedia={this.props.onOpenMedia} card={status.card} />;
|
media = <Card onOpenMedia={this.props.onOpenMedia} card={status.card} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,18 +3,22 @@
|
||||||
* Converts API cards into our internal format.
|
* Converts API cards into our internal format.
|
||||||
* @see {@link https://docs.joinmastodon.org/entities/card/}
|
* @see {@link https://docs.joinmastodon.org/entities/card/}
|
||||||
*/
|
*/
|
||||||
|
import punycode from 'punycode';
|
||||||
|
|
||||||
import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable';
|
import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable';
|
||||||
|
|
||||||
|
import { mergeDefined } from 'soapbox/utils/normalizers';
|
||||||
|
|
||||||
// https://docs.joinmastodon.org/entities/card/
|
// https://docs.joinmastodon.org/entities/card/
|
||||||
export const CardRecord = ImmutableRecord({
|
export const CardRecord = ImmutableRecord({
|
||||||
author_name: '',
|
author_name: '',
|
||||||
author_url: '',
|
author_url: '',
|
||||||
blurhash: null,
|
blurhash: null as string | null,
|
||||||
description: '',
|
description: '',
|
||||||
embed_url: '',
|
embed_url: '',
|
||||||
height: 0,
|
height: 0,
|
||||||
html: '',
|
html: '',
|
||||||
image: null,
|
image: null as string | null,
|
||||||
provider_name: '',
|
provider_name: '',
|
||||||
provider_url: '',
|
provider_url: '',
|
||||||
title: '',
|
title: '',
|
||||||
|
@ -23,8 +27,44 @@ export const CardRecord = ImmutableRecord({
|
||||||
width: 0,
|
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 normalizePleromaOpengraph = (card: ImmutableMap<string, any>) => {
|
||||||
|
const opengraph = ImmutableMap({
|
||||||
|
width: card.getIn(['pleroma', 'opengraph', 'width']),
|
||||||
|
height: card.getIn(['pleroma', 'opengraph', 'height']),
|
||||||
|
html: card.getIn(['pleroma', 'opengraph', 'html']),
|
||||||
|
image: card.getIn(['pleroma', 'opengraph', 'thumbnail_url']),
|
||||||
|
});
|
||||||
|
|
||||||
|
return card.mergeWith(mergeDefined, opengraph);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Set provider from URL if not found */
|
||||||
|
const normalizeProviderName = (card: ImmutableMap<string, any>) => {
|
||||||
|
const providerName = card.get('provider_name') || decodeIDNA(getHostname(card.get('url')));
|
||||||
|
return card.set('provider_name', providerName);
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeCard = (card: Record<string, any>) => {
|
export const normalizeCard = (card: Record<string, any>) => {
|
||||||
return CardRecord(
|
return CardRecord(
|
||||||
ImmutableMap(fromJS(card)),
|
ImmutableMap(fromJS(card)).withMutations(card => {
|
||||||
|
normalizePleromaOpengraph(card);
|
||||||
|
normalizeProviderName(card);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Use new value only if old value is undefined
|
/** Use new value only if old value is undefined */
|
||||||
export const mergeDefined = (oldVal: any, newVal: any) => oldVal === undefined ? newVal : oldVal;
|
export const mergeDefined = (oldVal: any, newVal: any) => oldVal === undefined ? newVal : oldVal;
|
||||||
|
|
||||||
export const makeEmojiMap = (emojis: any) => emojis.reduce((obj: any, emoji: any) => {
|
export const makeEmojiMap = (emojis: any) => emojis.reduce((obj: any, emoji: any) => {
|
||||||
|
|
Loading…
Reference in New Issue