Files
ncd-fe/packages/pl-fe/src/components/preview-card.tsx
nicole mikołajczyk 04f2740401 pl-fe: make it partially work
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
2025-12-19 21:42:10 +01:00

305 lines
8.7 KiB
TypeScript

import { Link } from '@tanstack/react-router';
import clsx from 'clsx';
import { type MediaAttachment, type PreviewCard as CardEntity, mediaAttachmentSchema } from 'pl-api';
import React, { useState, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import * as v from 'valibot';
import Blurhash from 'pl-fe/components/blurhash';
import HStack from 'pl-fe/components/ui/hstack';
import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import Emojify from 'pl-fe/features/emoji/emojify';
import { addAutoPlay } from 'pl-fe/utils/media';
import { getTextDirection } from 'pl-fe/utils/rtl';
import HoverAccountWrapper from './hover-account-wrapper';
import Avatar from './ui/avatar';
/** Props for `PreviewCard`. */
interface IPreviewCard {
card: CardEntity;
maxTitle?: number;
maxDescription?: number;
onOpenMedia: (attachments: Array<MediaAttachment>, index: number) => void;
compact?: boolean;
defaultWidth?: number;
cacheWidth?: (width: number) => void;
horizontal?: boolean;
}
/** Displays a Mastodon link preview. Similar to OEmbed. */
const PreviewCard: React.FC<IPreviewCard> = ({
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 direction = getTextDirection(card.title + card.description);
const trimmedTitle = trim(card.title, maxTitle);
const trimmedDescription = trim(card.description, maxDescription);
const handlePhotoClick = () => {
const attachment = v.parse(mediaAttachmentSchema, {
id: '',
type: 'image',
url: card.embed_url,
description: trimmedTitle,
meta: {
original: {
width: card.width,
height: card.height,
},
},
});
onOpenMedia([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 = clsx('status-card relative z-[1] flex-col bg-white black:bg-black dark:bg-primary-900 md:flex-row', { 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()}
href={card.url}
title={trimmedTitle}
rel='noopener'
target='_blank'
dir={direction}
>
<span dir={direction}>{trimmedTitle}</span>
</a>
) : (
<span title={trimmedTitle} dir={direction}>{trimmedTitle}</span>
);
const description = (
<Stack space={2} className='flex-1 overflow-hidden p-4'>
{trimmedTitle && (
<Text weight='bold' direction={direction}>{title}</Text>
)}
{trimmedDescription && (
<Text direction={direction}>{trimmedDescription}</Text>
)}
<HStack space={1} alignItems='center'>
<Text tag='span' theme='muted'>
<Icon src={require('@phosphor-icons/core/regular/link-simple.svg')} />
</Text>
<Text tag='span' theme='muted' size='sm' direction={direction}>
{card.provider_name}
</Text>
</HStack>
</Stack>
);
let embed: React.ReactNode = null;
const canvas = (
<Blurhash
className='absolute inset-0 -z-10 size-full'
hash={card.blurhash}
/>
);
const thumbnail = (
<div
style={{
backgroundImage: `url(${card.image})`,
width: horizontal ? width : undefined,
height: horizontal ? height : undefined,
}}
className='status-card__image-image'
title={card.image_description || undefined}
/>
);
if (interactive) {
if (embedded) {
embed = renderVideo();
} else {
let iconVariant = require('@phosphor-icons/core/regular/play.svg');
if (card.type === 'photo') {
iconVariant = require('@phosphor-icons/core/regular/magnifying-glass-plus.svg');
}
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
<div className='absolute inset-0 flex items-center justify-center'>
<div className='flex items-center justify-center rounded-full bg-gray-500/90 px-4 py-3 shadow-md dark:bg-gray-700/90'>
<HStack space={3} alignItems='center'>
<button onClick={handleEmbedClick} className='appearance-none text-gray-700 hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-100'>
<Icon
src={iconVariant}
className='size-6 text-inherit'
/>
</button>
{horizontal && (
<a
onClick={(e) => e.stopPropagation()}
href={card.url}
target='_blank'
rel='noopener'
className='text-gray-700 hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-100'
>
<Icon
src={require('@phosphor-icons/core/regular/arrow-square-out.svg')}
className='size-6 text-inherit'
/>
</a>
)}
</HStack>
</div>
</div>
</div>
);
}
return (
<div className={className} ref={setRef}>
{embed}
{description}
</div>
);
} else if (card.image) {
embed = (
<div className={clsx(
'status-card__image',
'w-full flex-none rounded-l md:size-auto md:flex-auto',
{
'h-auto': horizontal,
'h-[200px]': !horizontal,
},
)}
>
{canvas}
{thumbnail}
</div>
);
}
const link = (
<a
href={card.url}
className={className}
target='_blank'
rel='noopener'
ref={setRef}
onClick={e => e.stopPropagation()}
>
{embed}
{description}
</a>
);
if (card.authors.length) {
return (
<Stack>
{link}
<div className='-mt-4 rounded-lg border border-t-0 border-solid border-gray-200 bg-gray-100 p-2 pt-6 black:bg-primary-900 dark:border-gray-800 dark:bg-primary-700'>
<Text theme='muted' className='flex items-center gap-2'>
<FormattedMessage
id='link_preview.more_from_author'
defaultMessage='More from {name}'
values={{
name: card.authors.map(author => (
<HoverAccountWrapper key={author.url} accountId={author.account?.id} element='bdi'>
<Link to='/@{$username}' params={{ username: author.account?.acct || '' }}>
<HStack space={1} alignItems='center'>
{author.account && (
<Avatar src={author.account?.avatar} size={16} username={author.account.username} />
)}
<Text weight='medium'>
<Emojify text={author.account?.display_name || author.name} emojis={author.account?.emojis} />
</Text>
</HStack>
</Link>
</HoverAccountWrapper>
)),
}}
/>
</Text>
</div>
</Stack>
);
}
return link;
};
/** Trim the text, adding ellipses if it's too long. */
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 ? '…' : '');
};
export { PreviewCard as default };