bigbuffet-rw/app/soapbox/components/status.tsx

423 lines
13 KiB
TypeScript
Raw Normal View History

import classNames from 'classnames';
2022-08-08 19:39:08 -07:00
import React, { useEffect, useRef, useState } from 'react';
import { HotKeys } from 'react-hotkeys';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
2022-08-08 19:39:08 -07:00
import { NavLink, useHistory } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
import AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status';
import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions';
2022-01-10 14:01:24 -08:00
import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
2022-03-21 11:09:01 -07:00
import { HStack, Text } from './ui';
2020-03-27 13:59:38 -07:00
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import type {
Account as AccountEntity,
Attachment as AttachmentEntity,
Status as StatusEntity,
} from 'soapbox/types/entities';
// Defined in components/scrollable_list
2022-04-16 11:43:55 -07:00
export type ScrollPosition = { height: number, top: number };
const messages = defineMessages({
reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' },
});
2022-08-08 19:39:08 -07:00
interface IStatus {
2022-06-02 11:32:08 -07:00
id?: string,
contextType?: string,
status: StatusEntity,
account: AccountEntity,
otherAccounts: ImmutableList<AccountEntity>,
onClick: () => void,
onReply: (status: StatusEntity) => void,
onFavourite: (status: StatusEntity) => void,
onReblog: (status: StatusEntity, e?: KeyboardEvent) => void,
onQuote: (status: StatusEntity) => void,
onDelete: (status: StatusEntity) => void,
onEdit: (status: StatusEntity) => void,
onDirect: (status: StatusEntity) => void,
onChat: (status: StatusEntity) => void,
onMention: (account: StatusEntity['account']) => void,
onPin: (status: StatusEntity) => void,
onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void,
onOpenVideo: (media: ImmutableMap<string, any> | AttachmentEntity, startTime: number) => void,
onOpenAudio: (media: ImmutableMap<string, any>, startTime: number) => void,
onBlock: (status: StatusEntity) => void,
onEmbed: (status: StatusEntity) => void,
onHeightChange: (status: StatusEntity) => void,
onToggleHidden: (status: StatusEntity) => void,
onShowHoverProfileCard: (status: StatusEntity) => void,
muted: boolean,
hidden: boolean,
unread: boolean,
2022-06-02 11:32:08 -07:00
onMoveUp: (statusId: string, featured?: boolean) => void,
onMoveDown: (statusId: string, featured?: boolean) => void,
getScrollPosition?: () => ScrollPosition | undefined,
updateScrollBottom?: (bottom: number) => void,
group: ImmutableMap<string, any>,
displayMedia: string,
allowedEmoji: ImmutableList<string>,
focusable: boolean,
2022-06-02 11:32:08 -07:00
featured?: boolean,
2022-06-02 12:00:35 -07:00
withDismiss?: boolean,
2022-06-16 21:19:53 -07:00
hideActionBar?: boolean,
2022-06-20 15:37:20 -07:00
hoverable?: boolean,
}
2022-08-08 19:39:08 -07:00
const Status: React.FC<IStatus> = (props) => {
const {
status,
focusable = true,
hoverable = true,
onToggleHidden,
displayMedia,
onOpenMedia,
onOpenVideo,
onClick,
onReply,
onFavourite,
onReblog,
onMention,
onMoveUp,
onMoveDown,
muted,
hidden,
featured,
unread,
group,
hideActionBar,
} = props;
const intl = useIntl();
const history = useHistory();
const didShowCard = useRef(false);
const node = useRef<HTMLDivElement>(null);
const [showMedia, setShowMedia] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
const [emojiSelectorFocused, setEmojiSelectorFocused] = useState(false);
// Track height changes we know about to compensate scrolling.
useEffect(() => {
didShowCard.current = Boolean(!muted && !hidden && status?.card);
}, []);
useEffect(() => {
setShowMedia(defaultMediaVisibility(status, displayMedia));
}, [status.id]);
const handleToggleMediaVisibility = (): void => {
setShowMedia(!showMedia);
2020-03-27 13:59:38 -07:00
};
2022-08-08 19:39:08 -07:00
const handleClick = (): void => {
if (onClick) {
onClick();
2020-03-27 13:59:38 -07:00
} else {
2022-08-08 19:39:08 -07:00
history.push(`/@${_properStatus().getIn(['account', 'acct'])}/posts/${_properStatus().id}`);
2020-03-27 13:59:38 -07:00
}
2022-08-08 19:39:08 -07:00
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleExpandedToggle = (): void => {
onToggleHidden(_properStatus());
2020-03-27 13:59:38 -07:00
};
2022-08-08 19:39:08 -07:00
const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => {
const status = _properStatus();
const firstAttachment = status.media_attachments.first();
2021-08-28 05:17:14 -07:00
e?.preventDefault();
2021-08-28 05:17:14 -07:00
if (firstAttachment) {
if (firstAttachment.type === 'video') {
onOpenVideo(firstAttachment, 0);
2021-08-28 05:17:14 -07:00
} else {
onOpenMedia(status.media_attachments, 0);
2021-08-28 05:17:14 -07:00
}
}
2022-08-08 19:39:08 -07:00
};
2021-08-28 05:17:14 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyReply = (e?: KeyboardEvent): void => {
e?.preventDefault();
2022-08-08 19:39:08 -07:00
onReply(_properStatus());
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyFavourite = (): void => {
onFavourite(_properStatus());
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyBoost = (e?: KeyboardEvent): void => {
onReblog(_properStatus(), e);
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyMention = (e?: KeyboardEvent): void => {
e?.preventDefault();
2022-08-08 19:39:08 -07:00
onMention(_properStatus().account);
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyOpen = (): void => {
history.push(`/@${_properStatus().getIn(['account', 'acct'])}/posts/${_properStatus().id}`);
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyOpenProfile = (): void => {
history.push(`/@${_properStatus().getIn(['account', 'acct'])}`);
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyMoveUp = (e?: KeyboardEvent): void => {
onMoveUp(status.id, featured);
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyMoveDown = (e?: KeyboardEvent): void => {
onMoveDown(status.id, featured);
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyToggleHidden = (): void => {
onToggleHidden(_properStatus());
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const handleHotkeyToggleSensitive = (): void => {
handleToggleMediaVisibility();
};
2022-08-08 19:39:08 -07:00
const handleHotkeyReact = (): void => {
_expandEmojiSelector();
};
2022-08-08 19:39:08 -07:00
const handleEmojiSelectorUnfocus = (): void => {
setEmojiSelectorFocused(false);
};
2022-08-08 19:39:08 -07:00
const _expandEmojiSelector = (): void => {
setEmojiSelectorFocused(true);
const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji?.focus();
};
2022-08-08 19:39:08 -07:00
const _properStatus = (): StatusEntity => {
if (status.reblog && typeof status.reblog === 'object') {
return status.reblog;
2020-03-27 13:59:38 -07:00
} else {
return status;
}
2022-08-08 19:39:08 -07:00
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
if (!status) return null;
const actualStatus = _properStatus();
let prepend, rebloggedByText, reblogElement, reblogElementMobile;
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
if (hidden) {
return (
<div ref={node}>
{actualStatus.getIn(['account', 'display_name']) || actualStatus.getIn(['account', 'username'])}
{actualStatus.content}
</div>
);
}
2022-08-08 19:39:08 -07:00
if (status.filtered || actualStatus.filtered) {
const minHandlers = muted ? undefined : {
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
};
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
return (
<HotKeys handlers={minHandlers}>
<div className={classNames('status__wrapper', 'status__wrapper--filtered', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
2020-03-27 13:59:38 -07:00
</div>
2022-08-08 19:39:08 -07:00
</HotKeys>
);
}
2022-03-21 11:09:01 -07:00
2022-08-08 19:39:08 -07:00
if (featured) {
prepend = (
<div className='pt-4 px-4'>
<HStack alignItems='center' space={1}>
<Icon src={require('@tabler/icons/pinned.svg')} className='text-gray-600 dark:text-gray-400' />
<Text size='sm' theme='muted' weight='medium'>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</Text>
</HStack>
</div>
);
}
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
if (status.reblog && typeof status.reblog === 'object') {
const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) };
reblogElement = (
<NavLink
to={`/@${status.getIn(['account', 'acct'])}`}
onClick={(event) => event.stopPropagation()}
className='hidden sm:flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline'
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
<HStack alignItems='center'>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi className='max-w-[100px] truncate pr-1'>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
</bdi>,
}}
/>
</HStack>
</NavLink>
);
2022-03-21 11:09:01 -07:00
2022-08-08 19:39:08 -07:00
reblogElementMobile = (
<div className='pb-5 -mt-2 sm:hidden truncate'>
2022-03-21 11:09:01 -07:00
<NavLink
to={`/@${status.getIn(['account', 'acct'])}`}
onClick={(event) => event.stopPropagation()}
2022-08-08 19:39:08 -07:00
className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline'
2022-03-21 11:09:01 -07:00
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
2022-03-21 11:09:01 -07:00
2022-08-08 19:39:08 -07:00
<span>
2022-03-21 11:09:01 -07:00
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
2022-08-08 19:39:08 -07:00
name: <bdi>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
2022-03-21 11:09:01 -07:00
</bdi>,
}}
/>
2022-08-08 19:39:08 -07:00
</span>
2022-03-21 11:09:01 -07:00
</NavLink>
2022-08-08 19:39:08 -07:00
</div>
);
2022-03-21 11:09:01 -07:00
2022-08-08 19:39:08 -07:00
rebloggedByText = intl.formatMessage(
messages.reblogged_by,
{ name: String(status.getIn(['account', 'acct'])) },
);
}
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
let quote;
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07: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 13:59:38 -07:00
}
2022-08-08 19:39:08 -07: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 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
return (
<HotKeys handlers={handlers} data-testid='status'>
<div
className={classNames('status cursor-pointer', { focusable })}
tabIndex={focusable && !muted ? 0 : undefined}
data-featured={featured ? 'true' : null}
aria-label={textForScreenReader(intl, actualStatus, rebloggedByText)}
ref={node}
onClick={() => history.push(statusUrl)}
role='link'
>
{prepend}
2022-08-08 19:39:08 -07:00
<div
className={classNames('status__wrapper', `status-${actualStatus.visibility}`, {
'status-reply': !!status.in_reply_to_id,
muted,
read: unread === false,
})}
data-id={status.id}
>
{reblogElementMobile}
<div className='mb-4'>
<AccountContainer
key={String(actualStatus.getIn(['account', 'id']))}
id={String(actualStatus.getIn(['account', 'id']))}
timestamp={actualStatus.created_at}
timestampUrl={statusUrl}
action={reblogElement}
hideActions={!reblogElement}
showEdit={!!actualStatus.edited_at}
showProfileHoverCard={hoverable}
withLinkToProfile={hoverable}
/>
</div>
2022-08-08 19:39:08 -07:00
<div className='status__content-wrapper'>
{!group && actualStatus.group && (
<div className='status__meta'>
Posted in <NavLink to={`/groups/${actualStatus.getIn(['group', 'id'])}`}>{String(actualStatus.getIn(['group', 'title']))}</NavLink>
</div>
)}
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
<StatusReplyMentions
status={actualStatus}
hoverable={hoverable}
/>
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
<StatusContent
status={actualStatus}
onClick={handleClick}
expanded={!status.hidden}
onExpandedToggle={handleExpandedToggle}
collapsable
/>
2022-01-04 12:06:08 -08:00
2022-08-08 19:39:08 -07:00
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
2020-03-27 13:59:38 -07:00
2022-08-08 19:39:08 -07:00
{quote}
2022-08-08 19:39:08 -07:00
{!hideActionBar && (
// @ts-ignore
<StatusActionBar
emojiSelectorFocused={emojiSelectorFocused}
handleEmojiSelectorUnfocus={handleEmojiSelectorUnfocus}
{...props}
status={actualStatus}
/>
)}
2020-03-27 13:59:38 -07:00
</div>
</div>
2022-08-08 19:39:08 -07:00
</div>
</HotKeys>
);
};
2022-08-08 19:39:08 -07:00
export default Status;