Merge branch 'next-status-clickery' into 'next'

Next: Convert Status Card to TSX+FC, refactor

See merge request soapbox-pub/soapbox-fe!1229
next-virtuoso
Alex Gleason 2 years ago
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…
Cancel
Save