543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
import { Link, linkOptions, useNavigate, useRouter } from '@tanstack/react-router';
|
||
import clsx from 'clsx';
|
||
import React, { useEffect, useMemo, useRef } from 'react';
|
||
import { defineMessages, useIntl, FormattedList, FormattedMessage } from 'react-intl';
|
||
|
||
import { mentionCompose, replyCompose } from '@/actions/compose';
|
||
import { unfilterStatus } from '@/actions/statuses';
|
||
import Card from '@/components/ui/card';
|
||
import Icon from '@/components/ui/icon';
|
||
import Text from '@/components/ui/text';
|
||
import AccountContainer from '@/containers/account-container';
|
||
import Emojify from '@/features/emoji/emojify';
|
||
import StatusTypeIcon from '@/features/status/components/status-type-icon';
|
||
import { Hotkeys } from '@/features/ui/components/hotkeys';
|
||
import { useAppDispatch } from '@/hooks/use-app-dispatch';
|
||
import { useAppSelector } from '@/hooks/use-app-selector';
|
||
import { useFollowedTags } from '@/queries/hashtags/use-followed-tags';
|
||
import { useFavouriteStatus, useReblogStatus, useUnfavouriteStatus, useUnreblogStatus } from '@/queries/statuses/use-status-interactions';
|
||
import { makeGetStatus, type SelectedStatus } from '@/selectors';
|
||
import { useModalsActions } from '@/stores/modals';
|
||
import { useSettings } from '@/stores/settings';
|
||
import { useStatusMetaActions } from '@/stores/status-meta';
|
||
import { textForScreenReader } from '@/utils/status';
|
||
|
||
import EventPreview from './event-preview';
|
||
import HashtagLink from './hashtag-link';
|
||
import RelativeTimestamp from './relative-timestamp';
|
||
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 IStatusFollowedTagInfo {
|
||
status: SelectedStatus;
|
||
avatarSize: number;
|
||
}
|
||
|
||
const StatusFollowedTagInfo: React.FC<IStatusFollowedTagInfo> = ({ status, avatarSize }) => {
|
||
const { data: followedTags } = useFollowedTags();
|
||
|
||
const filteredTags = status.tags.filter(tag => followedTags?.some(followed => followed.name.toLowerCase() === tag.name.toLowerCase()));
|
||
|
||
if (!filteredTags.length) {
|
||
return null;
|
||
}
|
||
|
||
const tagLinks = filteredTags.slice(0, 2).map(tag => (
|
||
<HashtagLink key={tag.name} hashtag={tag.name} />
|
||
));
|
||
|
||
if (filteredTags.length > 2) {
|
||
tagLinks.push(
|
||
<FormattedMessage key='more' id='reply_mentions.more' defaultMessage='{count} more' values={{ count: filteredTags.length - 2 }} />,
|
||
);
|
||
}
|
||
|
||
return (
|
||
<StatusInfo
|
||
className='-mb-1'
|
||
avatarSize={avatarSize}
|
||
icon={<Icon src={require('@phosphor-icons/core/regular/hash.svg')} className='size-4 text-primary-600 dark:text-primary-400' />}
|
||
text={
|
||
<FormattedMessage
|
||
id='status.followed_tag'
|
||
defaultMessage='You’re following {tags}'
|
||
values={{
|
||
tags: <FormattedList type='conjunction' value={tagLinks} />,
|
||
}}
|
||
/>
|
||
}
|
||
/>
|
||
);
|
||
};
|
||
|
||
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;
|
||
showInfo?: boolean;
|
||
fromBookmarks?: boolean;
|
||
fromHomeTimeline?: boolean;
|
||
className?: string;
|
||
}
|
||
|
||
const Status: React.FC<IStatus> = (props) => {
|
||
const {
|
||
status,
|
||
avatarSize = 42,
|
||
focusable = true,
|
||
hoverable = true,
|
||
onClick,
|
||
onMoveUp,
|
||
onMoveDown,
|
||
muted,
|
||
featured,
|
||
unread,
|
||
hideActionBar,
|
||
variant = 'rounded',
|
||
showGroup = true,
|
||
showInfo = true,
|
||
fromBookmarks = false,
|
||
fromHomeTimeline = false,
|
||
className,
|
||
} = props;
|
||
|
||
const intl = useIntl();
|
||
const navigate = useNavigate();
|
||
const dispatch = useAppDispatch();
|
||
const router = useRouter();
|
||
|
||
const { toggleStatusesMediaHidden } = useStatusMetaActions();
|
||
const { openModal } = useModalsActions();
|
||
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 group = actualStatus.group;
|
||
|
||
const filterResults = useMemo(() => {
|
||
return [...status.filtered, ...actualStatus.filtered].filter(({ filter }) => filter.filter_action === 'warn').reduce((uniqueFilters, current) => {
|
||
if (!uniqueFilters.some(({ filter: uniqueFilter }) => uniqueFilter.id === current.filter.id)) {
|
||
uniqueFilters.push(current);
|
||
}
|
||
return uniqueFilters;
|
||
}, [] as typeof status.filtered);
|
||
}, [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;
|
||
}
|
||
|
||
const link = linkOptions({
|
||
to: '/@{$username}/posts/$statusId',
|
||
params: { username: actualStatus.account.acct, statusId: actualStatus.id },
|
||
});
|
||
|
||
if (!e || !(e.ctrlKey || e.metaKey)) {
|
||
if (onClick) {
|
||
onClick();
|
||
} else {
|
||
navigate(link);
|
||
}
|
||
} else {
|
||
const url = router.buildLocation(link).href;
|
||
window.open(url, '_blank');
|
||
}
|
||
};
|
||
|
||
const handleHotkeyOpenMedia = (e?: KeyboardEvent) => {
|
||
const status = actualStatus;
|
||
|
||
e?.preventDefault();
|
||
|
||
if (status.media_attachments.length > 0) {
|
||
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 = () => {
|
||
navigate({ to: '/@{$username}/posts/$statusId', params: { username: actualStatus.account.acct, statusId: actualStatus.id } });
|
||
};
|
||
|
||
const handleHotkeyOpenProfile = () => {
|
||
navigate({ to: '/@{$username}', params: { username: 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 (!showInfo) return null;
|
||
|
||
if (isReblog && showGroup && group) {
|
||
return (
|
||
<StatusInfo
|
||
className='-mb-1'
|
||
avatarSize={avatarSize}
|
||
icon={<Icon src={require('@phosphor-icons/core/regular/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='/@{$username}'
|
||
params={{ username: 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/$groupId' params={{ groupId: 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='/@{$username}' params={{ username: 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
|
||
className='-mb-1'
|
||
avatarSize={avatarSize}
|
||
icon={<Icon src={require('@phosphor-icons/core/regular/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
|
||
className='-mb-1'
|
||
avatarSize={avatarSize}
|
||
icon={<Icon src={require('@phosphor-icons/core/regular/push-pin.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
|
||
className='-mb-1'
|
||
avatarSize={avatarSize}
|
||
icon={<Icon src={require('@phosphor-icons/core/regular/users-three.svg')} className='size-4 text-primary-600 dark:text-primary-400' />}
|
||
text={
|
||
<FormattedMessage
|
||
id='status.group'
|
||
defaultMessage='Posted in {group}'
|
||
values={{
|
||
group: (
|
||
<Link to='/groups/$groupId' params={{ groupId: 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>
|
||
),
|
||
}}
|
||
/>
|
||
}
|
||
/>
|
||
);
|
||
} else if (fromHomeTimeline) {
|
||
return <StatusFollowedTagInfo status={actualStatus} avatarSize={avatarSize} />;
|
||
}
|
||
}, [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')} 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-primary-400' 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} element='article'>
|
||
{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', {
|
||
'⁂-status--reply': !!status.in_reply_to_id,
|
||
})}
|
||
data-featured={featured ? 'true' : null}
|
||
data-visibility={actualStatus.visibility}
|
||
data-id={status.id}
|
||
aria-label={textForScreenReader(intl, actualStatus, rebloggedByText)}
|
||
ref={node}
|
||
onClick={handleClick}
|
||
role='link'
|
||
>
|
||
<Card
|
||
variant={variant}
|
||
className={clsx('⁂-status__wrapper status-wrapper', className, {
|
||
'py-6 sm:p-5': variant === 'rounded',
|
||
muted,
|
||
read: unread === false,
|
||
})}
|
||
>
|
||
{statusInfo}
|
||
|
||
{actualStatus.account_id && (
|
||
<div className='flex'>
|
||
<AccountContainer
|
||
key={actualStatus.account_id}
|
||
id={actualStatus.account_id}
|
||
action={
|
||
<div className='flex flex-row-reverse items-center gap-1 self-baseline'>
|
||
<Link to='/@{$username}/posts/$statusId' params={{ username: actualStatus.account.acct, statusId: actualStatus.id }} className='hover:underline' onClick={(event) => event.stopPropagation()}>
|
||
<RelativeTimestamp timestamp={actualStatus.created_at} theme='muted' size='sm' className='whitespace-nowrap' />
|
||
</Link>
|
||
<StatusTypeIcon visibility={actualStatus.visibility} />
|
||
<StatusLanguagePicker status={actualStatus} />
|
||
{!!actualStatus.edited_at && (
|
||
<>
|
||
<span className='⁂-separator' />
|
||
|
||
<Icon className='size-4 text-gray-700 dark:text-gray-600' src={require('@phosphor-icons/core/regular/pencil-simple.svg')} />
|
||
</>
|
||
)}
|
||
</div>
|
||
}
|
||
showAccountHoverCard={hoverable}
|
||
withLinkToProfile={hoverable}
|
||
approvalStatus={actualStatus.approval_status}
|
||
avatarSize={avatarSize}
|
||
actionAlignment='top'
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className='status__content-wrapper'>
|
||
<StatusReplyMentions status={actualStatus} hoverable={hoverable} />
|
||
|
||
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
|
||
<StatusContent
|
||
status={actualStatus}
|
||
onClick={handleClick}
|
||
collapsable
|
||
translatable
|
||
withMedia
|
||
/>
|
||
)}
|
||
|
||
<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} element='article' data-testid='status'>
|
||
{body}
|
||
</Hotkeys>
|
||
);
|
||
};
|
||
|
||
export {
|
||
type IStatus,
|
||
Status as default,
|
||
};
|