Convert Status Card to TSX+FC, trim obscenely long titles
This commit is contained in:
parent
bf8de20e0d
commit
53a54bcc96
4 changed files with 312 additions and 4 deletions
Binary file not shown.
255
app/soapbox/features/status/components/card.tsx
Normal file
255
app/soapbox/features/status/components/card.tsx
Normal file
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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<string, any>) => {
|
||||
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<string, any>) => {
|
||||
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<string, any>) => {
|
||||
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<string, any>) => {
|
||||
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<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>) => {
|
||||
return CardRecord(
|
||||
ImmutableMap(fromJS(card)),
|
||||
ImmutableMap(fromJS(card)).withMutations(card => {
|
||||
normalizeWidth(card);
|
||||
normalizeHeight(card);
|
||||
normalizeHtml(card);
|
||||
normalizeImage(card);
|
||||
normalizeProviderName(card);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue