soapbox/app/soapbox/components/status.tsx

435 wiersze
13 KiB
TypeScript
Czysty Zwykły widok Historia

2023-02-06 18:01:03 +00:00
import clsx from 'clsx';
2022-08-09 02:39:08 +00:00
import React, { useEffect, useRef, useState } from 'react';
import { HotKeys } from 'react-hotkeys';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
2023-01-09 14:03:37 +00:00
import { useHistory } from 'react-router-dom';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon';
import TranslateButton from 'soapbox/components/translate-button';
2022-11-15 16:13:54 +00:00
import AccountContainer from 'soapbox/containers/account-container';
2022-11-15 19:00:40 +00:00
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
import { useAppDispatch, useSettings } from 'soapbox/hooks';
2022-08-09 03:26:30 +00:00
import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status';
import EventPreview from './event-preview';
import StatusActionBar from './status-action-bar';
2022-11-15 16:13:54 +00:00
import StatusContent from './status-content';
import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions';
2022-10-20 16:15:37 +00:00
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
2023-01-05 18:44:05 +00:00
import StatusInfo from './statuses/status-info';
import { Card, Stack, Text } from './ui';
2020-03-27 20:59:38 +00:00
import type {
Account as AccountEntity,
Group as GroupEntity,
Status as StatusEntity,
} from 'soapbox/types/entities';
// Defined in components/scrollable-list
2022-04-16 18:43:55 +00:00
export type ScrollPosition = { height: number, top: number };
const messages = defineMessages({
reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' },
});
export interface IStatus {
id?: string
avatarSize?: number
status: StatusEntity
onClick?: () => void
muted?: boolean
hidden?: boolean
unread?: boolean
onMoveUp?: (statusId: string, featured?: boolean) => void
onMoveDown?: (statusId: string, featured?: boolean) => void
focusable?: boolean
featured?: boolean
hideActionBar?: boolean
hoverable?: boolean
variant?: 'default' | 'rounded'
showGroup?: boolean
accountAction?: React.ReactElement
}
2022-08-09 02:39:08 +00:00
const Status: React.FC<IStatus> = (props) => {
const {
status,
2023-01-06 17:05:13 +00:00
accountAction,
2023-01-05 18:44:05 +00:00
avatarSize = 42,
2022-08-09 02:39:08 +00:00
focusable = true,
hoverable = true,
onClick,
onMoveUp,
onMoveDown,
muted,
hidden,
featured,
unread,
hideActionBar,
2022-08-12 17:58:35 +00:00
variant = 'rounded',
showGroup = true,
2022-08-09 02:39:08 +00:00
} = props;
2022-08-21 18:47:23 +00:00
2022-08-09 02:39:08 +00:00
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
2022-08-09 02:39:08 +00:00
const settings = useSettings();
const displayMedia = settings.get('displayMedia') as string;
2022-08-09 02:39:08 +00:00
const didShowCard = useRef(false);
const node = useRef<HTMLDivElement>(null);
const overlay = useRef<HTMLDivElement>(null);
2022-08-09 02:39:08 +00:00
const [showMedia, setShowMedia] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
const [minHeight, setMinHeight] = useState(208);
2022-08-09 02:39:08 +00:00
2022-08-09 03:46:09 +00:00
const actualStatus = getActualStatus(status);
2023-01-05 18:44:05 +00:00
const isReblog = status.reblog && typeof status.reblog === 'object';
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
const group = actualStatus.group as GroupEntity | null;
2022-08-09 02:39:08 +00:00
// Track height changes we know about to compensate scrolling.
useEffect(() => {
didShowCard.current = Boolean(!muted && !hidden && status?.card);
}, []);
useEffect(() => {
setShowMedia(defaultMediaVisibility(status, displayMedia));
}, [status.id]);
useEffect(() => {
if (overlay.current) {
setMinHeight(overlay.current.getBoundingClientRect().height);
}
}, [overlay.current]);
2022-08-09 02:39:08 +00:00
const handleToggleMediaVisibility = (): void => {
setShowMedia(!showMedia);
2020-03-27 20:59:38 +00:00
};
const handleClick = (e?: React.MouseEvent): void => {
e?.stopPropagation();
// If the user is selecting text, don't focus the status.
if (getSelection()?.toString().length) {
return;
}
if (!e || !(e.ctrlKey || e.metaKey)) {
if (onClick) {
onClick();
} else {
history.push(statusUrl);
}
2020-03-27 20:59:38 +00:00
} else {
window.open(statusUrl, '_blank');
2020-03-27 20:59:38 +00:00
}
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => {
2022-08-09 03:46:09 +00:00
const status = actualStatus;
const firstAttachment = status.media_attachments.first();
2021-08-28 12:17:14 +00:00
e?.preventDefault();
2021-08-28 12:17:14 +00:00
if (firstAttachment) {
if (firstAttachment.type === 'video') {
dispatch(openModal('VIDEO', { status, media: firstAttachment, time: 0 }));
2021-08-28 12:17:14 +00:00
} else {
dispatch(openModal('MEDIA', { status, media: status.media_attachments, index: 0 }));
2021-08-28 12:17:14 +00:00
}
}
2022-08-09 02:39:08 +00:00
};
2021-08-28 12:17:14 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyReply = (e?: KeyboardEvent): void => {
e?.preventDefault();
dispatch(replyCompose(actualStatus));
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyFavourite = (): void => {
toggleFavourite(actualStatus);
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyBoost = (e?: KeyboardEvent): void => {
const modalReblog = () => dispatch(toggleReblog(actualStatus));
const boostModal = settings.get('boostModal');
if ((e && e.shiftKey) || !boostModal) {
modalReblog();
} else {
dispatch(openModal('BOOST', { status: actualStatus, onReblog: modalReblog }));
}
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyMention = (e?: KeyboardEvent): void => {
e?.preventDefault();
dispatch(mentionCompose(actualStatus.account as AccountEntity));
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyOpen = (): void => {
history.push(statusUrl);
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyOpenProfile = (): void => {
2022-08-09 03:46:09 +00:00
history.push(`/@${actualStatus.getIn(['account', 'acct'])}`);
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyMoveUp = (e?: KeyboardEvent): void => {
if (onMoveUp) {
onMoveUp(status.id, featured);
}
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyMoveDown = (e?: KeyboardEvent): void => {
if (onMoveDown) {
onMoveDown(status.id, featured);
}
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyToggleHidden = (): void => {
dispatch(toggleStatusHidden(actualStatus));
2022-08-09 02:39:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
const handleHotkeyToggleSensitive = (): void => {
handleToggleMediaVisibility();
};
2022-08-09 02:39:08 +00:00
const handleHotkeyReact = (): void => {
_expandEmojiSelector();
};
2022-08-09 02:39:08 +00:00
const _expandEmojiSelector = (): void => {
const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji?.focus();
};
2023-01-05 18:44:05 +00:00
const renderStatusInfo = () => {
if (isReblog) {
return (
<StatusInfo
avatarSize={avatarSize}
to={`/@${status.getIn(['account', 'acct'])}`}
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />}
text={
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: (
<bdi className='truncate pr-1 rtl:pl-1'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: String(status.getIn(['account', 'display_name_html'])),
}}
/>
</bdi>
),
}}
/>
}
/>
);
} else if (featured) {
return (
<StatusInfo
avatarSize={avatarSize}
icon={<Icon src={require('@tabler/icons/pinned.svg')} className='text-gray-600 dark:text-gray-400' />}
text={
<Text size='xs' theme='muted' weight='medium'>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</Text>
}
/>
);
} else if (showGroup && group) {
return (
<StatusInfo
avatarSize={avatarSize}
to={`/groups/${group.id}`}
icon={<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />}
text={
<Text size='xs' theme='muted' weight='medium'>
<FormattedMessage
id='status.group'
defaultMessage='Posted in {group}'
values={{ group: (
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
) }}
/>
</Text>
}
/>
);
2023-01-05 18:44:05 +00:00
}
};
2022-08-09 02:39:08 +00:00
if (!status) return null;
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
if (hidden) {
return (
<div ref={node}>
2023-01-10 23:03:15 +00:00
<>
{actualStatus.getIn(['account', 'display_name']) || actualStatus.getIn(['account', 'username'])}
{actualStatus.content}
</>
2022-08-09 02:39:08 +00:00
</div>
);
}
2022-08-09 02:39:08 +00:00
if (status.filtered || actualStatus.filtered) {
const minHandlers = muted ? undefined : {
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
};
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
return (
<HotKeys handlers={minHandlers}>
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
</Text>
2020-03-27 20:59:38 +00:00
</div>
2022-08-09 02:39:08 +00:00
</HotKeys>
);
}
2022-03-21 18:09:01 +00:00
2023-01-05 18:44:05 +00:00
let rebloggedByText;
2022-08-09 02:39:08 +00:00
if (status.reblog && typeof status.reblog === 'object') {
rebloggedByText = intl.formatMessage(
messages.reblogged_by,
{ name: String(status.getIn(['account', 'acct'])) },
);
}
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
let quote;
2020-03-27 20:59:38 +00:00
2022-08-09 02:39:08 +00:00
if (actualStatus.quote) {
if (actualStatus.pleroma.get('quote_visible', true) === false) {
quote = (
<div className='quoted-status-tombstone'>
<p><FormattedMessage id='statuses.quote_tombstone' defaultMessage='Post is unavailable.' /></p>
</div>
);
} else {
quote = <QuotedStatus statusId={actualStatus.quote as string} />;
2020-03-27 20:59:38 +00:00
}
2022-08-09 02:39:08 +00:00
}
const handlers = muted ? undefined : {
reply: handleHotkeyReply,
favourite: handleHotkeyFavourite,
boost: handleHotkeyBoost,
mention: handleHotkeyMention,
open: handleHotkeyOpen,
openProfile: handleHotkeyOpenProfile,
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
toggleHidden: handleHotkeyToggleHidden,
toggleSensitive: handleHotkeyToggleSensitive,
openMedia: handleHotkeyOpenMedia,
react: handleHotkeyReact,
};
2020-03-27 20:59:38 +00:00
2022-11-09 18:43:15 +00:00
const isUnderReview = actualStatus.visibility === 'self';
const isSensitive = actualStatus.hidden;
2022-09-29 14:44:06 +00:00
2022-08-09 02:39:08 +00:00
return (
<HotKeys handlers={handlers} data-testid='status'>
<div
2023-02-06 18:01:03 +00:00
className={clsx('status cursor-pointer', { focusable })}
2022-08-09 02:39:08 +00:00
tabIndex={focusable && !muted ? 0 : undefined}
data-featured={featured ? 'true' : null}
aria-label={textForScreenReader(intl, actualStatus, rebloggedByText)}
ref={node}
onClick={handleClick}
2022-08-09 02:39:08 +00:00
role='link'
>
2022-08-12 17:58:35 +00:00
<Card
variant={variant}
2023-02-06 18:01:03 +00:00
className={clsx('status__wrapper space-y-4', `status-${actualStatus.visibility}`, {
2022-08-22 16:11:01 +00:00
'py-6 sm:p-5': variant === 'rounded',
2022-08-09 02:39:08 +00:00
'status-reply': !!status.in_reply_to_id,
muted,
read: unread === false,
})}
data-id={status.id}
>
2023-01-05 18:44:05 +00:00
{renderStatusInfo()}
<AccountContainer
key={String(actualStatus.getIn(['account', 'id']))}
id={String(actualStatus.getIn(['account', 'id']))}
2023-01-06 17:03:57 +00:00
timestamp={actualStatus.created_at}
timestampUrl={statusUrl}
2023-01-05 18:44:05 +00:00
action={accountAction}
hideActions={!accountAction}
showEdit={!!actualStatus.edited_at}
showProfileHoverCard={hoverable}
withLinkToProfile={hoverable}
approvalStatus={actualStatus.approval_status}
2023-01-05 18:44:05 +00:00
avatarSize={avatarSize}
/>
<div className='status__content-wrapper'>
2022-11-03 19:57:33 +00:00
<StatusReplyMentions status={actualStatus} hoverable={hoverable} />
<Stack
className='relative z-0'
style={{ minHeight: isUnderReview || isSensitive ? Math.max(minHeight, 208) + 12 : undefined }}
>
2022-11-09 18:43:15 +00:00
{(isUnderReview || isSensitive) && (
2022-10-20 16:15:37 +00:00
<SensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
ref={overlay}
/>
)}
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
<Stack space={4}>
<StatusContent
status={actualStatus}
onClick={handleClick}
collapsable
translatable
/>
<TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{quote}
</Stack>
)}
</Stack>
)}
</Stack>
{(!hideActionBar && !isUnderReview) && (
2022-08-09 23:46:16 +00:00
<div className='pt-4'>
<StatusActionBar status={actualStatus} />
2022-08-09 23:46:16 +00:00
</div>
2022-08-09 02:39:08 +00:00
)}
2020-03-27 20:59:38 +00:00
</div>
2022-08-12 17:58:35 +00:00
</Card>
2022-09-29 14:44:06 +00:00
</div >
</HotKeys >
2022-08-09 02:39:08 +00:00
);
};
2022-08-09 02:39:08 +00:00
export default Status;