Files
ncd-fe/packages/pl-fe/src/components/status.tsx
nicole mikołajczyk aa43c2bd73 pl-fe: don't display duplicated status info
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
2026-02-07 23:18:44 +01:00

543 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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='Youre 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,
};