Files
Mastodon/app/javascript/mastodon/components/status/status.tsx
T

354 lines
9.0 KiB
TypeScript

import type React from 'react';
import { useCallback, useMemo } from 'react';
import classNames from 'classnames';
import type { Merge } from 'type-fest';
import { selectPlainAccount } from '@/mastodon/selectors/accounts';
import { selectStatusFilters } from '@/mastodon/selectors/filters';
import { selectExpandedStatus } from '@/mastodon/selectors/statuses';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
import { ContentWarning } from '../content_warning';
import { FilterWarning } from '../filter_warning';
import { computeHashtagBarForStatus, HashtagBar } from '../hashtag_bar';
import { Hotkeys } from '../hotkeys';
import { StatusActionBar } from './action_bar';
import { StatusAttachments } from './attachments';
import { StatusContent } from './content';
import { StatusHeader } from './header';
import type { StatusHandlers } from './hooks';
import { useStatusHandlers, useTextForScreenReader } from './hooks';
import { StatusPrepend } from './prepend';
import type { StatusContainerProps, StatusContextType } from './types';
type StatusRedesignProps = Merge<
Omit<StatusContainerProps, 'account'>,
{
accountId?: string;
contextType?: StatusContextType;
onClick?: () => void;
}
>;
const selectStatusReblog = createAppSelector(
[(state, id?: string | null) => selectExpandedStatus(state, id ?? undefined)],
(status) => {
if (!status) {
return {};
}
if (!status.reblog) {
return { status };
}
const { reblog, ...statusRest } = status;
return {
status: reblog,
parent: statusRest,
};
},
);
export const StatusRedesign: React.FC<StatusRedesignProps> = ({
id,
muted,
rootId,
previousId,
nextId,
unread,
skipPrepend,
unfocusable,
contextType,
featured,
isQuotedPost,
accountId,
hidden,
shouldHighlightOnMount,
showActions,
scrollKey,
children,
headerRenderFn,
avatarSize,
withCounters,
withDismiss,
onClick,
showThread,
}) => {
// Select data from store
const { status, parent } = useAppSelector((state) =>
selectStatusReblog(state, id),
);
const account = useAppSelector(
(state) =>
parent?.account ?? selectPlainAccount(state, accountId) ?? undefined,
);
const matchedFilters = useAppSelector((state) =>
selectStatusFilters(state, { contextType, statusId: parent?.id ?? id }),
);
const statusId = status?.id;
// Display
const screenReaderText = useTextForScreenReader({
statusId,
reblogAcct: parent?.account.acct,
isQuote: isQuotedPost,
});
const { statusContent, hashtagsInBar } = useMemo(
(): Partial<ReturnType<typeof computeHashtagBarForStatus>> =>
status ? computeHashtagBarForStatus(status) : {},
[status],
);
// Handlers
const {
showDespiteFilter,
onHeaderClick,
onExpandedToggle,
onFilterToggle,
onOpenClick,
onTranslate,
...handlers
} = useStatusHandlers({ status, contextType, onClick });
if (!status) {
return null; // loading state
}
const actualStatus = parent ?? status;
const expanded =
(matchedFilters.length === 0 || showDespiteFilter) &&
(!status.hidden || !status.spoiler_text);
const hotkeysProps = {
...handlers,
onTranslate,
muted,
unfocusable,
} satisfies Omit<React.ComponentProps<typeof StatusHotkeys>, 'children'>;
if (hidden) {
return (
<StatusHotkeys {...hotkeysProps}>
<div
className={classNames('status__wrapper', { focusable: !muted })}
tabIndex={unfocusable ? undefined : 0}
>
<span>{status.account.display_name || status.account.username}</span>
{status.spoiler_text && <span>{status.spoiler_text}</span>}
{expanded && <span>{status.content}</span>}
</div>
</StatusHotkeys>
);
}
const header = headerRenderFn ? (
headerRenderFn({
statusId: status.id,
account,
avatarSize,
onHeaderClick,
featured,
})
) : (
<StatusHeader
statusId={status.id}
account={account}
avatarSize={avatarSize}
onHeaderClick={onHeaderClick}
/>
);
return (
<StatusHotkeys {...hotkeysProps}>
<div
className={classNames(
'status__wrapper',
`status__wrapper-${status.visibility}`,
{
'status__wrapper-reply': !!status.in_reply_to_id,
'status__wrapper--in-thread': !!rootId,
unread,
focusable: !muted,
},
)}
tabIndex={muted || unfocusable ? undefined : 0}
data-featured={featured ? 'true' : null}
aria-label={screenReaderText}
data-nosnippet={status.account.noindex || undefined}
>
{!skipPrepend && (
<StatusPrepend
status={actualStatus}
isReblog={!!parent}
showThread={showThread}
/>
)}
<StatusContentWrapper
statusId={status.id}
inReplyToId={actualStatus.in_reply_to_id}
rootId={rootId}
nextId={nextId}
previousId={previousId}
className={classNames(`status-${status.visibility}`, {
muted,
'status--is-quote': isQuotedPost,
'status--has-quote': !!status.quote,
'status--highlighted-entry': shouldHighlightOnMount,
})}
>
{header}
{matchedFilters.length > 0 && (
<FilterWarning
title={matchedFilters.map((filter) => filter.title).join(', ')}
expanded={showDespiteFilter}
onClick={onFilterToggle}
/>
)}
{(matchedFilters.length === 0 || showDespiteFilter) && (
<ContentWarning
statusId={status.id}
expanded={expanded}
onClick={onExpandedToggle}
/>
)}
{expanded && (
<>
<StatusContent
statusId={status.id}
statusContent={statusContent}
onClick={onOpenClick}
onTranslate={onTranslate}
collapsible
/>
<StatusAttachments
statusId={status.id}
contextType={contextType}
/>
{hashtagsInBar && (
<HashtagBar
hashtags={hashtagsInBar}
accountId={status.account.id}
/>
)}
{children}
</>
)}
{showActions && !isQuotedPost && (
<StatusActionBar
scrollKey={scrollKey}
statusId={status.id}
contextType={contextType}
withDismiss={withDismiss}
withCounters={withCounters}
/>
)}
</StatusContentWrapper>
</div>
</StatusHotkeys>
);
};
const StatusHotkeys: React.FC<
{
muted?: boolean;
unfocusable?: boolean;
children: React.ReactNode;
} & Omit<
StatusHandlers,
| 'showDespiteFilter'
| 'onOpenClick'
| 'onHeaderClick'
| 'onExpandedToggle'
| 'onFilterToggle'
>
> = ({ muted, unfocusable, children, ...handlers }) => {
const onOpen = useCallback(() => {
handlers.onOpen();
}, [handlers]);
if (muted) {
return children;
}
return (
<Hotkeys
handlers={{
reply: handlers.onReply,
favourite: handlers.onFavourite,
boost: handlers.onBoost,
quote: handlers.onQuote,
mention: handlers.onMention,
open: onOpen,
openProfile: handlers.onOpenProfile,
toggleHidden: handlers.onToggleHidden,
// TODO: This is handled in a child component, so needs to be fixed.
// toggleSensitive: onMediaShowToggle,
openMedia: handlers.onOpenMedia,
onTranslate: handlers.onTranslate,
}}
focusable={!unfocusable}
>
{children}
</Hotkeys>
);
};
const StatusContentWrapper: React.FC<
Pick<StatusRedesignProps, 'rootId' | 'previousId' | 'nextId' | 'children'> & {
statusId: string;
inReplyToId?: string;
className?: string;
}
> = ({
statusId,
inReplyToId,
rootId,
previousId,
nextId,
className,
children,
}) => {
const nextInReplyToId = useAppSelector((state) =>
nextId ? state.statuses.getIn([nextId, 'in_reply_to_id']) : null,
);
const connectUp = !!previousId && previousId === inReplyToId;
const connectToRoot = !!rootId && rootId === inReplyToId;
const connectReply = !!nextInReplyToId && nextInReplyToId === statusId;
return (
<div
className={classNames(
'status',
{
'status-reply': !!inReplyToId,
'status--in-thread': !!rootId,
'status--first-in-thread':
previousId && (!connectUp || connectToRoot),
},
className,
)}
data-id={statusId}
>
{(connectReply || connectUp || connectToRoot) && (
<div
className={classNames('status__line', {
'status__line--full': connectReply,
'status__line--first': !inReplyToId && !connectToRoot,
})}
/>
)}
{children}
</div>
);
};