Files
ncd-fe/packages/pl-fe/src/components/status.tsx
nicole mikołajczyk 6525b398b0 nicolium: put some aria-hidden
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
2026-02-18 13:57:34 +01:00

652 lines
19 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 RssFeedInfo from './rss-feed-info';
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 IAccountInfo {
status: SelectedStatus;
}
const AccountInfo: React.FC<IAccountInfo> = React.memo(({ status }) => (
<div className='flex flex-row-reverse items-center gap-1 self-baseline'>
<Link
to='/@{$username}/posts/$statusId'
params={{ username: status.account.acct, statusId: status.id }}
className='hover:underline'
onClick={(event) => {
event.stopPropagation();
}}
>
<RelativeTimestamp
timestamp={status.created_at}
theme='muted'
size='sm'
className='whitespace-nowrap'
/>
</Link>
<StatusTypeIcon visibility={status.visibility} />
<StatusLanguagePicker status={status} />
{!!status.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>
));
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'
aria-hidden
/>
}
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) => {
if (status.rss_feed) return;
e?.preventDefault();
dispatch(replyCompose(actualStatus, status.reblog_id ? status.account : undefined));
};
const handleHotkeyFavourite = (e?: KeyboardEvent) => {
if (status.rss_feed) return;
e?.preventDefault();
if (status.favourited) unfavouriteStatus();
else favouriteStatus();
};
const handleHotkeyBoost = (e?: KeyboardEvent) => {
if (status.rss_feed) return;
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) => {
if (status.rss_feed) return;
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 = () => {
if (status.rss_feed) return;
(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'
aria-hidden
/>
}
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'
aria-hidden
/>
}
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'
aria-hidden
/>
}
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'
aria-hidden
/>
}
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}
{status.rss_feed ? (
<RssFeedInfo feed={status.rss_feed} timestamp={status.created_at} />
) : (
actualStatus.account_id && (
<div className='flex'>
<AccountContainer
key={actualStatus.account_id}
id={actualStatus.account_id}
action={<AccountInfo status={actualStatus} />}
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
/>
)}
{!status.rss_feed && (
<>
<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 };