470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useEffect, useMemo, useRef } from 'react';
|
|
import { defineMessages, useIntl, FormattedList, FormattedMessage } from 'react-intl';
|
|
import { Link, useHistory } from 'react-router-dom';
|
|
|
|
import { mentionCompose, replyCompose } from 'pl-fe/actions/compose';
|
|
import { unfilterStatus } from 'pl-fe/actions/statuses';
|
|
import Card from 'pl-fe/components/ui/card';
|
|
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 AccountContainer from 'pl-fe/containers/account-container';
|
|
import Emojify from 'pl-fe/features/emoji/emojify';
|
|
import StatusTypeIcon from 'pl-fe/features/status/components/status-type-icon';
|
|
import { Hotkeys } from 'pl-fe/features/ui/components/hotkeys';
|
|
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
|
|
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
|
|
import { useSettings } from 'pl-fe/hooks/use-settings';
|
|
import { useFavouriteStatus, useReblogStatus, useUnfavouriteStatus, useUnreblogStatus } from 'pl-fe/queries/statuses/use-status-interactions';
|
|
import { makeGetStatus, type SelectedStatus } from 'pl-fe/selectors';
|
|
import { useModalsStore } from 'pl-fe/stores/modals';
|
|
import { useStatusMetaStore } from 'pl-fe/stores/status-meta';
|
|
import { textForScreenReader } from 'pl-fe/utils/status';
|
|
|
|
import EventPreview from './event-preview';
|
|
import StatusActionBar from './status-action-bar';
|
|
import StatusContent from './status-content';
|
|
import StatusLanguagePicker from './status-language-picker';
|
|
import StatusReactionsBar from './status-reactions-bar';
|
|
import StatusReplyMentions from './status-reply-mentions';
|
|
import StatusInfo from './statuses/status-info';
|
|
import Tombstone from './tombstone';
|
|
|
|
const messages = defineMessages({
|
|
reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' },
|
|
});
|
|
|
|
interface IStatus {
|
|
id?: string;
|
|
avatarSize?: number;
|
|
status: SelectedStatus;
|
|
onClick?: () => void;
|
|
muted?: 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' | 'slim';
|
|
showGroup?: boolean;
|
|
accountAction?: React.ReactElement;
|
|
fromBookmarks?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
const Status: React.FC<IStatus> = (props) => {
|
|
const {
|
|
status,
|
|
accountAction,
|
|
avatarSize = 42,
|
|
focusable = true,
|
|
hoverable = true,
|
|
onClick,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
muted,
|
|
featured,
|
|
unread,
|
|
hideActionBar,
|
|
variant = 'rounded',
|
|
showGroup = true,
|
|
fromBookmarks = false,
|
|
className,
|
|
} = props;
|
|
|
|
const intl = useIntl();
|
|
const history = useHistory();
|
|
const dispatch = useAppDispatch();
|
|
|
|
const { toggleStatusesMediaHidden } = useStatusMetaStore();
|
|
const { openModal } = useModalsStore();
|
|
const { boostModal } = useSettings();
|
|
const didShowCard = useRef(false);
|
|
const node = useRef<HTMLDivElement>(null);
|
|
|
|
const getStatus = useMemo(makeGetStatus, []);
|
|
const actualStatus = useAppSelector(state => status.reblog_id && getStatus(state, { id: status.reblog_id }) || status)!;
|
|
|
|
const { mutate: favouriteStatus } = useFavouriteStatus(actualStatus.id);
|
|
const { mutate: unfavouriteStatus } = useUnfavouriteStatus(actualStatus.id);
|
|
const { mutate: reblogStatus } = useReblogStatus(actualStatus.id);
|
|
const { mutate: unreblogStatus } = useUnreblogStatus(actualStatus.id);
|
|
|
|
const isReblog = status.reblog_id;
|
|
const statusUrl = `/@${actualStatus.account.acct}/posts/${actualStatus.id}`;
|
|
const group = actualStatus.group;
|
|
|
|
const filterResults = useMemo(() => [...status.filtered, ...actualStatus.filtered].filter(({ filter }) => filter.filter_action === 'warn'), [status.filtered, actualStatus.filtered]);
|
|
const filtered = filterResults.length > 0;
|
|
|
|
// Track height changes we know about to compensate scrolling.
|
|
useEffect(() => {
|
|
didShowCard.current = Boolean(!muted && status?.card);
|
|
}, []);
|
|
|
|
const handleClick = (e?: React.MouseEvent) => {
|
|
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);
|
|
}
|
|
} else {
|
|
window.open(statusUrl, '_blank');
|
|
}
|
|
};
|
|
|
|
const handleHotkeyOpenMedia = (e?: KeyboardEvent) => {
|
|
const status = actualStatus;
|
|
const firstAttachment = status.media_attachments[0];
|
|
|
|
e?.preventDefault();
|
|
|
|
if (firstAttachment) {
|
|
if (firstAttachment.type === 'video') {
|
|
openModal('VIDEO', { statusId: status.id, media: firstAttachment, time: 0 });
|
|
} else {
|
|
openModal('MEDIA', { statusId: status.id, media: status.media_attachments, index: 0 });
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleHotkeyReply = (e?: KeyboardEvent) => {
|
|
e?.preventDefault();
|
|
dispatch(replyCompose(actualStatus, status.reblog_id ? status.account : undefined));
|
|
};
|
|
|
|
const handleHotkeyFavourite = (e?: KeyboardEvent) => {
|
|
e?.preventDefault();
|
|
if (status.favourited) unfavouriteStatus();
|
|
else favouriteStatus();
|
|
};
|
|
|
|
const handleHotkeyBoost = (e?: KeyboardEvent) => {
|
|
const modalReblog = () => {
|
|
if (status.reblogged) unreblogStatus();
|
|
else reblogStatus(undefined);
|
|
};
|
|
if ((e && e.shiftKey) || !boostModal) {
|
|
modalReblog();
|
|
} else {
|
|
openModal('BOOST', { statusId: actualStatus.id, onReblog: modalReblog });
|
|
}
|
|
};
|
|
|
|
const handleHotkeyMention = (e?: KeyboardEvent) => {
|
|
e?.preventDefault();
|
|
dispatch(mentionCompose(actualStatus.account));
|
|
};
|
|
|
|
const handleHotkeyOpen = () => {
|
|
history.push(statusUrl);
|
|
};
|
|
|
|
const handleHotkeyOpenProfile = () => {
|
|
history.push(`/@${actualStatus.account.acct}`);
|
|
};
|
|
|
|
const handleHotkeyMoveUp = (e?: KeyboardEvent) => {
|
|
if (onMoveUp) {
|
|
onMoveUp(status.id, featured);
|
|
}
|
|
};
|
|
|
|
const handleHotkeyMoveDown = (e?: KeyboardEvent) => {
|
|
if (onMoveDown) {
|
|
onMoveDown(status.id, featured);
|
|
}
|
|
};
|
|
|
|
const handleHotkeyToggleSensitive = () => {
|
|
toggleStatusesMediaHidden([actualStatus.id]);
|
|
};
|
|
|
|
const handleHotkeyReact = () => {
|
|
(node.current?.querySelector('.emoji-picker-dropdown') as HTMLButtonElement)?.click();
|
|
};
|
|
|
|
const handleUnfilter = () => {
|
|
dispatch(unfilterStatus(actualStatus.id));
|
|
if (actualStatus.id !== status.id) dispatch(unfilterStatus(status.id));
|
|
};
|
|
|
|
const statusInfo = useMemo(() => {
|
|
if (isReblog && showGroup && group) {
|
|
return (
|
|
<StatusInfo
|
|
avatarSize={avatarSize}
|
|
icon={<Icon src={require('@tabler/icons/outline/repeat.svg')} className='size-4 text-green-600' />}
|
|
text={
|
|
<FormattedMessage
|
|
id='status.reblogged_by_with_group'
|
|
defaultMessage='{name} reposted from {group}'
|
|
values={{
|
|
name: (
|
|
<Link
|
|
to={`/@${status.account.acct}`}
|
|
className='hover:underline'
|
|
>
|
|
<bdi className='truncate'>
|
|
<strong className='text-gray-800 dark:text-gray-200'>
|
|
<Emojify text={status.account.display_name} emojis={status.account.emojis} />
|
|
</strong>
|
|
</bdi>
|
|
</Link>
|
|
),
|
|
group: (
|
|
<Link to={`/groups/${group.id}`} className='hover:underline'>
|
|
<strong className='text-gray-800 dark:text-gray-200'>
|
|
<Emojify text={group.display_name} emojis={group.emojis} />
|
|
</strong>
|
|
</Link>
|
|
),
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
);
|
|
} else if (isReblog) {
|
|
const accounts = status.accounts || [status.account];
|
|
|
|
const renderedAccounts = accounts.slice(0, 2).map(account => !!account && (
|
|
<Link key={account.acct} to={`/@${account.acct}`} className='hover:underline'>
|
|
<bdi className='truncate'>
|
|
<strong className='text-gray-800 dark:text-gray-200'>
|
|
<Emojify text={account.display_name} emojis={account.emojis} />
|
|
</strong>
|
|
</bdi>
|
|
</Link>
|
|
));
|
|
|
|
if (accounts.length > 2) {
|
|
renderedAccounts.push(
|
|
<FormattedMessage
|
|
id='notification.more'
|
|
defaultMessage='{count, plural, one {# other} other {# others}}'
|
|
values={{ count: accounts.length - renderedAccounts.length }}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
const values = {
|
|
name: <FormattedList type='conjunction' value={renderedAccounts} />,
|
|
count: accounts.length,
|
|
};
|
|
|
|
return (
|
|
<StatusInfo
|
|
avatarSize={avatarSize}
|
|
icon={<Icon src={require('@tabler/icons/outline/repeat.svg')} className='size-4 text-green-600' />}
|
|
text={
|
|
status.visibility === 'private' ? (
|
|
<FormattedMessage
|
|
id='status.reblogged_by_private'
|
|
defaultMessage='{name} reposted to followers'
|
|
values={values}
|
|
/>
|
|
) : (
|
|
<FormattedMessage
|
|
id='status.reblogged_by'
|
|
defaultMessage='{name} reposted'
|
|
values={values}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
} else if (featured) {
|
|
return (
|
|
<StatusInfo
|
|
avatarSize={avatarSize}
|
|
icon={<Icon src={require('@tabler/icons/outline/pinned.svg')} className='size-4 text-gray-600 dark:text-gray-400' />}
|
|
text={
|
|
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
|
|
}
|
|
/>
|
|
);
|
|
} else if (showGroup && group) {
|
|
return (
|
|
<StatusInfo
|
|
avatarSize={avatarSize}
|
|
icon={<Icon src={require('@tabler/icons/outline/circles.svg')} className='size-4 text-primary-600 dark:text-accent-blue' />}
|
|
text={
|
|
<FormattedMessage
|
|
id='status.group'
|
|
defaultMessage='Posted in {group}'
|
|
values={{
|
|
group: (
|
|
<Link to={`/groups/${group.id}`} className='hover:underline'>
|
|
<bdi className='truncate'>
|
|
<strong className='text-gray-800 dark:text-gray-200'>
|
|
<Emojify text={group.display_name} emojis={group.emojis} />
|
|
</strong>
|
|
</bdi>
|
|
</Link>
|
|
),
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
}, [status.accounts, group?.id]);
|
|
|
|
if (!status) return null;
|
|
|
|
if (status.deleted) return (
|
|
<Tombstone id={status.id} onMoveUp={onMoveUp} onMoveDown={onMoveDown} deleted />
|
|
);
|
|
|
|
if (filtered && actualStatus.showFiltered !== true) {
|
|
const body = (
|
|
<div className={clsx('status__wrapper text-center', { focusable })} ref={node}>
|
|
<Text theme='muted'>
|
|
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {filterResults.map(({ filter }) => filter.title).join(', ')}.
|
|
{' '}
|
|
<button className='text-primary-600 hover:underline dark:text-accent-blue' onClick={handleUnfilter}>
|
|
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
|
|
</button>
|
|
</Text>
|
|
</div>
|
|
);
|
|
|
|
if (muted) return body;
|
|
|
|
const minHandlers = {
|
|
moveUp: handleHotkeyMoveUp,
|
|
moveDown: handleHotkeyMoveDown,
|
|
};
|
|
|
|
return (
|
|
<Hotkeys handlers={minHandlers} focusable={focusable}>
|
|
{body}
|
|
</Hotkeys>
|
|
);
|
|
}
|
|
|
|
let rebloggedByText;
|
|
if (status.reblog_id === 'object') {
|
|
rebloggedByText = intl.formatMessage(
|
|
messages.reblogged_by,
|
|
{ name: status.account.acct },
|
|
);
|
|
}
|
|
|
|
const body = (
|
|
<div
|
|
className={clsx('status cursor-pointer', { focusable })}
|
|
data-featured={featured ? 'true' : null}
|
|
aria-label={textForScreenReader(intl, actualStatus, rebloggedByText)}
|
|
ref={node}
|
|
onClick={handleClick}
|
|
role='link'
|
|
>
|
|
<Card
|
|
variant={variant}
|
|
className={clsx('status__wrapper space-y-4', className, `status-${actualStatus.visibility}`, {
|
|
'py-6 sm:p-5': variant === 'rounded',
|
|
'status-reply': !!status.in_reply_to_id,
|
|
muted,
|
|
read: unread === false,
|
|
})}
|
|
data-id={status.id}
|
|
>
|
|
{statusInfo}
|
|
|
|
<AccountContainer
|
|
key={actualStatus.account_id}
|
|
id={actualStatus.account_id}
|
|
timestamp={actualStatus.created_at}
|
|
timestampUrl={statusUrl}
|
|
action={accountAction}
|
|
hideActions={!accountAction}
|
|
showEdit={!!actualStatus.edited_at}
|
|
showAccountHoverCard={hoverable}
|
|
withLinkToProfile={hoverable}
|
|
approvalStatus={actualStatus.approval_status}
|
|
avatarSize={avatarSize}
|
|
items={(
|
|
<>
|
|
<StatusTypeIcon visibility={actualStatus.visibility} />
|
|
<StatusLanguagePicker status={actualStatus} />
|
|
</>
|
|
)}
|
|
/>
|
|
|
|
<div className='status__content-wrapper'>
|
|
<StatusReplyMentions status={actualStatus} hoverable={hoverable} />
|
|
|
|
<Stack className='relative z-0'>
|
|
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
|
|
<StatusContent
|
|
status={actualStatus}
|
|
onClick={handleClick}
|
|
collapsable
|
|
translatable
|
|
withMedia
|
|
/>
|
|
)}
|
|
</Stack>
|
|
|
|
<StatusReactionsBar status={actualStatus} collapsed />
|
|
|
|
{!hideActionBar && (
|
|
<div
|
|
className={clsx({
|
|
'pt-2': actualStatus.emoji_reactions.length,
|
|
'pt-4': !actualStatus.emoji_reactions.length,
|
|
})}
|
|
>
|
|
<StatusActionBar
|
|
status={actualStatus}
|
|
rebloggedBy={isReblog ? status.account : undefined}
|
|
fromBookmarks={fromBookmarks}
|
|
expandable
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div >
|
|
);
|
|
|
|
if (muted) return body;
|
|
|
|
const handlers = {
|
|
reply: handleHotkeyReply,
|
|
favourite: handleHotkeyFavourite,
|
|
boost: handleHotkeyBoost,
|
|
mention: handleHotkeyMention,
|
|
open: handleHotkeyOpen,
|
|
openProfile: handleHotkeyOpenProfile,
|
|
moveUp: handleHotkeyMoveUp,
|
|
moveDown: handleHotkeyMoveDown,
|
|
toggleSensitive: handleHotkeyToggleSensitive,
|
|
openMedia: handleHotkeyOpenMedia,
|
|
react: handleHotkeyReact,
|
|
};
|
|
|
|
return (
|
|
<Hotkeys handlers={handlers} focusable={focusable} data-testid='status'>
|
|
{body}
|
|
</Hotkeys>
|
|
);
|
|
};
|
|
|
|
export {
|
|
type IStatus,
|
|
Status as default,
|
|
};
|