From 53a54bcc96cb7f6517cc9a2bab938caf396466cf Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 16 Apr 2022 12:48:05 -0500 Subject: [PATCH] Convert Status Card to TSX+FC, trim obscenely long titles --- .../features/status/components/card.js | Bin 7923 -> 0 bytes .../features/status/components/card.tsx | 255 ++++++++++++++++++ .../status/components/detailed-status.tsx | 2 +- app/soapbox/normalizers/card.ts | 59 +++- 4 files changed, 312 insertions(+), 4 deletions(-) delete mode 100644 app/soapbox/features/status/components/card.js create mode 100644 app/soapbox/features/status/components/card.tsx diff --git a/app/soapbox/features/status/components/card.js b/app/soapbox/features/status/components/card.js deleted file mode 100644 index f556ae3c80d70d93458a6f109e9f00b66fc8b586..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7923 zcmbVRdym^T690cc1q&3Bj9T`lm&+lW_2z;kMYgy!i=^!xPy`#UMcZs-NtNh*)HQIQ z=04bclKai@A~a zOSx37qv;kv<%rfow?p3l{=L{ZLcLtBjJzsTM|aUw^^bRT;8o*0Gqljryi;;&9E*m2 zcU$M4<2E0r%Ywj+_k>#OQ^JgsoYQkAA1YwUJ{*5dVhBd4FjUG|(< zY0w|xSZl=j%O9R!Uc7tt-TA-7L?pLmmUWxCA`24<_VQ{ebtxuiVtph;qs*!)-6Fn) z7`1hwO`437f&E^}I;|yKg(LLuNSC>~eLG7dnFBGIOvJHx7W_G~LiyyVE%a2y^rJ$R zbF&x>#0m3sqJFJ($&#NQZNk;&%Dk>xLlFS=S54t{w>O7Mt!nsU)728D98KX2qh1w? zzS2Y{gQvKOr5i08HM4AJ(Ouu~IWe(x)95AWQ=@LpP(W>5)tb(VpRQmSLA${&iAXFZ zt{{Y-iIj<8pzPs*tsVr^InrZ8lujAu)vm5uh%V>p@lfFSKzw6$aF!Eiqzmkw{P*Ag zNnpiIMc&JTYY<@fwfG2QCVQ4bhg3KEA5*JE+Cs+6viJt1=f=qvyJmg?Rnr zn|}Z`4|_buF_C z7~t|}%PGVIUJd-Wi@q~~fTSA`Lb)8d6}a)IbjW!?&g*3rePQ7=65!57e? zHPYy*AlS6+vkf$eo6oPRrxywK-JJQhmN7YO5y(5`G#ZepdQkMQ$DP}apEyUE6}_0TJdqu?QR zz(xDL0-y%AQe??FfS@Y5Owk3ne0Fw&Hvsn3Y$P>B1U|gU*wx#?LVSqL7Vb0c-XJv1 zO6|rVNZ)baF!j~nY1WA+MZ0K28uAN-#c1M zNz}tEY*o<&0V1;>$OW>vGH5uGv>y(0hj>z-SA=}Y};iMJf<%-V|;2AMDA>Huhqt#j4nQs>-$=6zNntG=02!)25CXzyzpImoubzh(! z|Bh@$SB^t53j~$#V`3=*EO1*$2@e@*j2*}uq-DzQn~y#G z;~vAIR$tp^;#7*NoSc>)yiX+k*>ACZIU*K4oRX7F+7hlLJf5P~)WPXxjAFpLR35?3EB4Z4tn zE@xqQ{G<^HhINt4uQeUab_Ieqx@=gxU>456F2{NutyRa+G+qflNjePqXCR;XK{jas6YMI(yZ{OhVUdUoeN zBg`OvySiP?vzrANh;||K>Ly#}f@e;Hd zTT7hTO48q4N2!kTYrl?Kj&8lDk<-UL5%ueUMcU~3au=)r zBvQvXz1L!Wb$ogJf8@r`5$=T|%hVG8W3q=|U)ny+bfGfJovw!pv9s5p7I(Is8)3?4 zpddIMqXdmkG>3F9JOm8~z+fkAg6aXwFbSTCePfU#?E-B(%=QIYNG { + 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 d1560c125d..0095f08223 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 1694926471..f222dc8f9e 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); + }), ); };