catstodon/app/javascript/mastodon/features/status/components/detailed_status.tsx
2024-11-05 14:40:07 +00:00

401 lines
12 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-unsafe-call,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-assignment */
import type { CSSProperties } from 'react';
import { useState, useRef, useCallback } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import type { StatusLike } from 'mastodon/components/hashtag_bar';
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon';
import { IconLogo } from 'mastodon/components/logo';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../components/status_content';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Video from '../../video';
import Card from './card';
interface VideoModalOptions {
startTime: number;
autoPlay?: boolean;
defaultVolume: number;
componentIndex: number;
}
export const DetailedStatus: React.FC<{
status: any;
onOpenMedia?: (status: any, index: number, lang: string) => void;
onOpenVideo?: (status: any, lang: string, options: VideoModalOptions) => void;
onTranslate?: (status: any) => void;
measureHeight?: boolean;
onHeightChange?: () => void;
domain: string;
showMedia?: boolean;
withLogo?: boolean;
overrideDisplayName?: React.ReactNode;
pictureInPicture: any;
onToggleHidden?: (status: any) => void;
onToggleMediaVisibility?: () => void;
}> = ({
status,
onOpenMedia,
onOpenVideo,
onTranslate,
measureHeight,
onHeightChange,
domain,
showMedia,
withLogo,
overrideDisplayName,
pictureInPicture,
onToggleMediaVisibility,
onToggleHidden,
}) => {
const properStatus = status?.get('reblog') ?? status;
const [height, setHeight] = useState(0);
const nodeRef = useRef<HTMLDivElement>();
const handleOpenVideo = useCallback(
(options: VideoModalOptions) => {
const lang = (status.getIn(['translation', 'language']) ||
status.get('language')) as string;
if (onOpenVideo)
onOpenVideo(status.getIn(['media_attachments', 0]), lang, options);
},
[onOpenVideo, status],
);
const handleExpandedToggle = useCallback(() => {
if (onToggleHidden) onToggleHidden(status);
}, [onToggleHidden, status]);
const _measureHeight = useCallback(
(heightJustChanged?: boolean) => {
if (measureHeight && nodeRef.current) {
scheduleIdleTask(() => {
if (nodeRef.current)
setHeight(Math.ceil(nodeRef.current.scrollHeight) + 1);
});
if (onHeightChange && heightJustChanged) {
onHeightChange();
}
}
},
[onHeightChange, measureHeight, setHeight],
);
const handleRef = useCallback(
(c: HTMLDivElement) => {
nodeRef.current = c;
_measureHeight();
},
[_measureHeight],
);
const handleTranslate = useCallback(() => {
if (onTranslate) onTranslate(status);
}, [onTranslate, status]);
if (!properStatus) {
return null;
}
let media;
let applicationLink;
let reblogLink;
let attachmentAspectRatio;
if (properStatus.get('media_attachments').getIn([0, 'type']) === 'video') {
attachmentAspectRatio = `${properStatus.get('media_attachments').getIn([0, 'meta', 'original', 'width'])} / ${properStatus.get('media_attachments').getIn([0, 'meta', 'original', 'height'])}`;
} else if (
properStatus.get('media_attachments').getIn([0, 'type']) === 'audio'
) {
attachmentAspectRatio = '16 / 9';
} else {
attachmentAspectRatio =
properStatus.get('media_attachments').size === 1 &&
properStatus
.get('media_attachments')
.getIn([0, 'meta', 'small', 'aspect'])
? properStatus
.get('media_attachments')
.getIn([0, 'meta', 'small', 'aspect'])
: '3 / 2';
}
const outerStyle = { boxSizing: 'border-box' } as CSSProperties;
if (measureHeight) {
outerStyle.height = height;
}
const language =
status.getIn(['translation', 'language']) || status.get('language');
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder aspectRatio={attachmentAspectRatio} />;
} else if (status.get('media_attachments').size > 0) {
if (
['image', 'gifv', 'unknown'].includes(
status.getIn(['media_attachments', 0, 'type']) as string,
) ||
status.get('media_attachments').size > 1
) {
media = (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
lang={language}
height={300}
onOpenMedia={onOpenMedia}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const description =
attachment.getIn(['translation', 'description']) ||
attachment.get('description');
media = (
<Audio
src={attachment.get('url')}
alt={description}
lang={language}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
poster={
attachment.get('preview_url') ||
status.getIn(['account', 'avatar_static'])
}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
sensitive={status.get('sensitive')}
visible={showMedia}
blurhash={attachment.get('blurhash')}
height={150}
onToggleVisibility={onToggleMediaVisibility}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
const description =
attachment.getIn(['translation', 'description']) ||
attachment.get('description');
media = (
<Video
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={description}
lang={language}
width={300}
height={150}
onOpenVideo={handleOpenVideo}
sensitive={status.get('sensitive')}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
);
}
} else if (status.get('spoiler_text').length === 0) {
media = (
<Card
sensitive={status.get('sensitive')}
onOpenMedia={onOpenMedia}
card={status.get('card', null)}
/>
);
}
if (status.get('application')) {
applicationLink = (
<>
·
<a
className='detailed-status__application'
href={status.getIn(['application', 'website'])}
target='_blank'
rel='noopener noreferrer'
>
{status.getIn(['application', 'name'])}
</a>
</>
);
}
const visibilityLink = (
<>
·<VisibilityIcon visibility={status.get('visibility')} />
</>
);
if (['private', 'direct'].includes(status.get('visibility') as string)) {
reblogLink = '';
} else {
reblogLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`}
className='detailed-status__link'
>
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
<FormattedMessage
id='status.reblogs'
defaultMessage='{count, plural, one {boost} other {boosts}}'
values={{ count: status.get('reblogs_count') }}
/>
</Link>
);
}
const favouriteLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`}
className='detailed-status__link'
>
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} />
</span>
<FormattedMessage
id='status.favourites'
defaultMessage='{count, plural, one {favorite} other {favorites}}'
values={{ count: status.get('favourites_count') }}
/>
</Link>
);
const { statusContentProps, hashtagBar } = getHashtagBarForStatus(
status as StatusLike,
);
const expanded =
!status.get('hidden') || status.get('spoiler_text').length === 0;
return (
<div style={outerStyle}>
<div ref={handleRef} className={classNames('detailed-status')}>
{status.get('visibility') === 'direct' && (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'>
<Icon
id='at'
icon={AlternateEmailIcon}
className='status__prepend-icon'
/>
</div>
<FormattedMessage
id='status.direct_indicator'
defaultMessage='Private mention'
/>
</div>
)}
<Link
to={`/@${status.getIn(['account', 'acct'])}`}
data-hover-card-account={status.getIn(['account', 'id'])}
className='detailed-status__display-name'
>
<div className='detailed-status__display-avatar'>
<Avatar account={status.get('account')} size={46} />
</div>
{overrideDisplayName ?? (
<DisplayName account={status.get('account')} localDomain={domain} />
)}
{withLogo && (
<>
<div className='spacer' />
<IconLogo />
</>
)}
</Link>
{status.get('spoiler_text').length > 0 && (
<ContentWarning
text={
status.getIn(['translation', 'spoilerHtml']) ||
status.get('spoilerHtml')
}
expanded={expanded}
onClick={handleExpandedToggle}
/>
)}
{expanded && (
<>
<StatusContent
status={status}
onTranslate={handleTranslate}
{...(statusContentProps as any)}
/>
{media}
{hashtagBar}
</>
)}
<div className='detailed-status__meta'>
<div className='detailed-status__meta__line'>
<a
className='detailed-status__datetime'
href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`}
target='_blank'
rel='noopener noreferrer'
>
<FormattedDate
value={new Date(status.get('created_at') as string)}
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
</a>
{visibilityLink}
{applicationLink}
</div>
{status.get('edited_at') && (
<div className='detailed-status__meta__line'>
<EditedTimestamp
statusId={status.get('id')}
timestamp={status.get('edited_at')}
/>
</div>
)}
<div className='detailed-status__meta__line'>
{reblogLink}
{reblogLink && <>·</>}
{favouriteLink}
</div>
</div>
</div>
</div>
);
};