mirror of
https://github.com/mastodon/mastodon.git
synced 2026-08-11 07:15:37 -05:00
Refactor Status component (#39731)
This commit is contained in:
@@ -35,6 +35,8 @@ import {
|
||||
editStatus,
|
||||
muteStatus,
|
||||
setStatusQuotePolicy,
|
||||
translateStatus,
|
||||
undoStatusTranslation,
|
||||
unmuteStatus,
|
||||
} from './statuses';
|
||||
|
||||
@@ -53,7 +55,8 @@ export type StatusInteractionIntent =
|
||||
| 'redraft'
|
||||
| 'reply'
|
||||
| 'report'
|
||||
| 'revokeQuote';
|
||||
| 'revokeQuote'
|
||||
| 'translate';
|
||||
|
||||
const messages = defineMessages({
|
||||
noEdits: {
|
||||
@@ -77,12 +80,16 @@ export const statusInteraction = createAppThunk(
|
||||
contextType,
|
||||
intent,
|
||||
}: {
|
||||
statusId: string;
|
||||
statusId?: string;
|
||||
contextType?: StatusContextType;
|
||||
intent: StatusInteractionIntent;
|
||||
},
|
||||
{ getState, dispatch },
|
||||
) => {
|
||||
if (!statusId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const statusImmutable = state.statuses.get(statusId);
|
||||
if (!statusImmutable) {
|
||||
@@ -255,6 +262,12 @@ export const statusInteraction = createAppThunk(
|
||||
}),
|
||||
);
|
||||
return;
|
||||
case 'translate':
|
||||
if (status.translation) {
|
||||
dispatch(undoStatusTranslation(statusId, status.poll));
|
||||
} else {
|
||||
dispatch(translateStatus(statusId));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,10 +13,8 @@ import type {
|
||||
MediaAttachmentShape,
|
||||
} from '@/mastodon/models/status';
|
||||
import { isMediaAttachmentOfType } from '@/mastodon/models/status';
|
||||
import {
|
||||
selectMediaMatchFilters,
|
||||
selectPictureInPicture,
|
||||
} from '@/mastodon/selectors/statuses';
|
||||
import { selectMediaFilters } from '@/mastodon/selectors/filters';
|
||||
import { selectPictureInPicture } from '@/mastodon/selectors/statuses';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { compareUrls } from '@/mastodon/utils/compare_urls';
|
||||
|
||||
@@ -125,7 +123,7 @@ const MediaAttachments: React.FC<{
|
||||
]) as Immutable.List<MediaAttachment>;
|
||||
});
|
||||
const mediaFilters = useAppSelector((state) =>
|
||||
selectMediaMatchFilters(state, { statusId, contextType }),
|
||||
selectMediaFilters(state, { statusId, contextType }),
|
||||
);
|
||||
const pictureInPicture = useAppSelector((state) =>
|
||||
selectPictureInPicture(state, statusId),
|
||||
|
||||
262
app/javascript/mastodon/components/status/hooks.ts
Normal file
262
app/javascript/mastodon/components/status/hooks.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
import { mentionComposeById } from '@/mastodon/actions/compose';
|
||||
import type { StatusInteractionIntent } from '@/mastodon/actions/interactions_typed';
|
||||
import { statusInteraction } from '@/mastodon/actions/interactions_typed';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { toggleStatusSpoilers } from '@/mastodon/actions/statuses';
|
||||
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
import type { ExpandedStatusShape } from '@/mastodon/models/status';
|
||||
import { selectStatusFilters } from '@/mastodon/selectors/filters';
|
||||
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { FOCUS_TARGET } from '../navigation_focus_target';
|
||||
|
||||
import type { StatusContextType } from './types';
|
||||
|
||||
const messages = defineMessages({
|
||||
quote_noun: {
|
||||
id: 'status.quote_noun',
|
||||
defaultMessage: 'Quote',
|
||||
description: 'Quote as a noun',
|
||||
},
|
||||
contains_quote: {
|
||||
id: 'status.contains_quote',
|
||||
defaultMessage: 'Contains quote',
|
||||
},
|
||||
boosted: { id: 'status.reblogged_by', defaultMessage: '{name} boosted' },
|
||||
});
|
||||
|
||||
export function useStatusHandlers({
|
||||
status,
|
||||
contextType,
|
||||
onClick,
|
||||
}: {
|
||||
status?: ExpandedStatusShape;
|
||||
contextType?: StatusContextType;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const matchedFilters = useAppSelector((state) =>
|
||||
selectStatusFilters(state, {}),
|
||||
);
|
||||
const [showDespiteFilter, { onToggle: onFilterToggle }] = useToggle(false);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const statusId = status?.id;
|
||||
|
||||
// Display handlers
|
||||
const onExpandedToggle = useCallback(() => {
|
||||
dispatch(toggleStatusSpoilers(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
const onToggleHidden = useCallback(() => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
if (!matchedFilters.length || showDespiteFilter) {
|
||||
dispatch(toggleStatusSpoilers(status.id));
|
||||
}
|
||||
|
||||
if (!status.hidden || !status.spoiler_text) {
|
||||
onFilterToggle();
|
||||
}
|
||||
}, [
|
||||
dispatch,
|
||||
matchedFilters.length,
|
||||
onFilterToggle,
|
||||
showDespiteFilter,
|
||||
status,
|
||||
]);
|
||||
|
||||
// Interaction handlers
|
||||
const handlerFactory = useCallback(
|
||||
(intent: StatusInteractionIntent) => {
|
||||
return () => {
|
||||
dispatch(statusInteraction({ statusId, intent, contextType }));
|
||||
};
|
||||
},
|
||||
[contextType, dispatch, statusId],
|
||||
);
|
||||
|
||||
const accountId = status?.account.id;
|
||||
const onMention = useCallback(() => {
|
||||
dispatch(mentionComposeById(accountId));
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
// Navigation handlers
|
||||
const history = useHistory();
|
||||
|
||||
const onOpen = useCallback(
|
||||
(newTab = false) => {
|
||||
if (onClick || !status) {
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const path = `/@${status.account.acct}/${status.id}`;
|
||||
|
||||
if (newTab) {
|
||||
window.open(path, '_blank', 'noopener');
|
||||
} else if (history.location.pathname.replace('/deck/', '/') === path) {
|
||||
history.replace(path, { focusTarget: FOCUS_TARGET.POST });
|
||||
} else {
|
||||
history.push(path, { focusTarget: FOCUS_TARGET.POST });
|
||||
}
|
||||
},
|
||||
[history, onClick, status],
|
||||
);
|
||||
|
||||
const onOpenClick: React.MouseEventHandler = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
|
||||
onOpen();
|
||||
} else if (
|
||||
event.button === 1 ||
|
||||
(event.button === 0 && (event.ctrlKey || event.metaKey))
|
||||
) {
|
||||
onOpen(true);
|
||||
}
|
||||
},
|
||||
[onOpen],
|
||||
);
|
||||
|
||||
const onHeaderClick: React.MouseEventHandler = useCallback(
|
||||
(event) => {
|
||||
// Only handle clicks on the empty space above the content
|
||||
if (event.target !== event.currentTarget && event.detail >= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenClick(event);
|
||||
},
|
||||
[onOpenClick],
|
||||
);
|
||||
|
||||
const acct = status?.account.acct;
|
||||
const onOpenProfile = useCallback(() => {
|
||||
if (acct) {
|
||||
history.push(`/@${acct}`);
|
||||
}
|
||||
}, [history, acct]);
|
||||
|
||||
const onOpenMedia = useCallback(() => {
|
||||
const attachment = status?.media_attachments[0];
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = status.translation?.language ?? status.language;
|
||||
if (attachment.type === 'video') {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'VIDEO',
|
||||
modalProps: {
|
||||
statusId: status.id,
|
||||
media: attachment,
|
||||
lang,
|
||||
options: { startTime: 0 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'MEDIA',
|
||||
modalProps: {
|
||||
statusId: status.id,
|
||||
media: status.media_attachments,
|
||||
lang,
|
||||
index: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [dispatch, status]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
showDespiteFilter,
|
||||
onOpenClick,
|
||||
onExpandedToggle,
|
||||
onFilterToggle,
|
||||
onHeaderClick,
|
||||
onMention,
|
||||
onOpen,
|
||||
onOpenMedia,
|
||||
onOpenProfile,
|
||||
onToggleHidden,
|
||||
onReply: handlerFactory('reply'),
|
||||
onFavourite: handlerFactory('favourite'),
|
||||
onBoost: handlerFactory('reblog'),
|
||||
onQuote: handlerFactory('quote'),
|
||||
onTranslate: handlerFactory('translate'),
|
||||
}),
|
||||
[
|
||||
handlerFactory,
|
||||
onExpandedToggle,
|
||||
onFilterToggle,
|
||||
onHeaderClick,
|
||||
onMention,
|
||||
onOpen,
|
||||
onOpenClick,
|
||||
onOpenMedia,
|
||||
onOpenProfile,
|
||||
onToggleHidden,
|
||||
showDespiteFilter,
|
||||
],
|
||||
);
|
||||
}
|
||||
export type StatusHandlers = ReturnType<typeof useStatusHandlers>;
|
||||
|
||||
const domParser = new DOMParser();
|
||||
export function useTextForScreenReader({
|
||||
statusId,
|
||||
reblogAcct,
|
||||
isQuote = false,
|
||||
}: {
|
||||
statusId?: string | null;
|
||||
reblogAcct?: string;
|
||||
isQuote?: boolean;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
const status = useExpandedStatus(statusId);
|
||||
return useMemo(() => {
|
||||
if (!status) {
|
||||
return '';
|
||||
}
|
||||
const displayName = status.account.display_name;
|
||||
|
||||
const spoilerText = status.translation?.spoiler_text ?? status.spoiler_text;
|
||||
const contentHtml = status.translation?.contentHtml ?? status.contentHtml;
|
||||
const contentText = domParser.parseFromString(contentHtml, 'text/html')
|
||||
.documentElement.textContent;
|
||||
|
||||
const values = [
|
||||
isQuote ? intl.formatMessage(messages.quote_noun) : undefined,
|
||||
displayName.length === 0
|
||||
? status.account.acct.split('@')[0]
|
||||
: displayName,
|
||||
spoilerText && status.hidden ? spoilerText : contentText,
|
||||
status.quote ? intl.formatMessage(messages.contains_quote) : undefined,
|
||||
intl.formatDate(status.created_at, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
status.account.acct,
|
||||
reblogAcct
|
||||
? intl.formatMessage(messages.boosted, { name: reblogAcct })
|
||||
: false,
|
||||
].filter((val) => !!val);
|
||||
|
||||
return values.join(', ');
|
||||
}, [intl, isQuote, reblogAcct, status]);
|
||||
}
|
||||
67
app/javascript/mastodon/components/status/prepend.tsx
Normal file
67
app/javascript/mastodon/components/status/prepend.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { ExpandedStatusShape } from '@/mastodon/models/status';
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
|
||||
import { LinkedDisplayName } from '../display_name';
|
||||
import { Icon } from '../icon';
|
||||
import { StatusThreadLabel } from '../status_thread_label';
|
||||
|
||||
export const StatusPrepend: React.FC<{
|
||||
status: ExpandedStatusShape;
|
||||
showThread?: boolean;
|
||||
isReblog?: boolean;
|
||||
}> = ({ status, showThread, isReblog }) => {
|
||||
if (isReblog) {
|
||||
return (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend__icon'>
|
||||
<Icon id='retweet' icon={RepeatIcon} />
|
||||
</div>
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by'
|
||||
defaultMessage='{name} boosted'
|
||||
values={{
|
||||
name: (
|
||||
<LinkedDisplayName
|
||||
displayProps={{
|
||||
account: status.account,
|
||||
variant: 'simple',
|
||||
}}
|
||||
className='status__display-name muted'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
tagName='span'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.visibility === 'direct') {
|
||||
return (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend__icon'>
|
||||
<Icon id='at' icon={AlternateEmailIcon} />
|
||||
</div>
|
||||
<FormattedMessage
|
||||
id='status.direct_indicator'
|
||||
defaultMessage='Private mention'
|
||||
tagName='span'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showThread && status.in_reply_to_account_id) {
|
||||
return (
|
||||
<StatusThreadLabel
|
||||
accountId={status.account.id}
|
||||
inReplyToAccountId={status.in_reply_to_account_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
353
app/javascript/mastodon/components/status/status.tsx
Normal file
353
app/javascript/mastodon/components/status/status.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -25,8 +25,9 @@ export interface StatusContainerProps {
|
||||
id?: string | null;
|
||||
account?: TAccount;
|
||||
children?: ReactNode;
|
||||
previousId?: string;
|
||||
rootId?: string;
|
||||
previousId?: string;
|
||||
nextId?: string;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
muted?: boolean;
|
||||
hidden?: boolean;
|
||||
@@ -54,7 +55,7 @@ export const TypedStatusContainer =
|
||||
StatusContainer as ComponentType<StatusContainerProps>;
|
||||
|
||||
// Taken from the Status component.
|
||||
export interface StatusProps extends StatusContainerProps {
|
||||
export interface StatusProps extends Omit<StatusContainerProps, 'nextId'> {
|
||||
status: TStatus;
|
||||
nextInReplyToId?: string;
|
||||
onReply: (status: TStatus) => void;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { selectExpandedStatus, selectPlainStatus } from '../selectors/statuses';
|
||||
import { useAppSelector } from '../store';
|
||||
|
||||
export function useStatus(id: string) {
|
||||
export function useStatus(id?: string | null) {
|
||||
return useAppSelector((state) => selectPlainStatus(state, id));
|
||||
}
|
||||
|
||||
export function useExpandedStatus(id: string) {
|
||||
return useAppSelector((state) => selectExpandedStatus(state, id));
|
||||
/** Adds reblog status and account information to standard Status */
|
||||
export function useExpandedStatus(id?: string | null) {
|
||||
return useAppSelector((state) =>
|
||||
selectExpandedStatus(state, id ?? undefined),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface StatusShape {
|
||||
collapsed: boolean | null;
|
||||
uri: string;
|
||||
url: string | null;
|
||||
isLoading?: boolean;
|
||||
|
||||
// Content
|
||||
content: string;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { createAppSelector } from 'mastodon/store';
|
||||
import { toServerSideType } from 'mastodon/utils/filters';
|
||||
|
||||
import type { StatusContextType } from '../components/status/types';
|
||||
|
||||
import { selectExpandedStatus } from './statuses';
|
||||
|
||||
export interface FilterShape {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -17,9 +19,9 @@ type Filter = Immutable.Map<string, unknown>;
|
||||
// TODO: move to `app/javascript/mastodon/models` and use more globally
|
||||
type FilterResult = Immutable.Map<string, unknown>;
|
||||
|
||||
export const getFilters = createSelector(
|
||||
export const getFilters = createAppSelector(
|
||||
[
|
||||
(state: RootState) => state.filters as Immutable.Map<string, Filter>,
|
||||
(state) => state.filters as Immutable.Map<string, Filter>,
|
||||
(_, { contextType }: { contextType?: string }) => contextType,
|
||||
],
|
||||
(filters, contextType) => {
|
||||
@@ -41,18 +43,93 @@ export const getFilters = createSelector(
|
||||
},
|
||||
);
|
||||
|
||||
export const getStatusHidden = (
|
||||
state: RootState,
|
||||
{ id, contextType }: { id: string; contextType: string },
|
||||
) => {
|
||||
const filters = getFilters(state, { contextType });
|
||||
if (filters === null) return false;
|
||||
export const selectPlainFilters = createAppSelector([getFilters], (filters) => {
|
||||
if (!filters) {
|
||||
return null;
|
||||
}
|
||||
return filters.toJS() as unknown as Record<string, FilterShape>;
|
||||
});
|
||||
|
||||
const filtered = state.statuses.getIn([id, 'filtered']) as
|
||||
| Immutable.List<FilterResult>
|
||||
| undefined;
|
||||
return filtered?.some(
|
||||
(result) =>
|
||||
filters.getIn([result.get('filter'), 'filter_action']) === 'hide',
|
||||
);
|
||||
};
|
||||
export const selectStatusFilters = createAppSelector(
|
||||
[
|
||||
(state, { statusId }: { statusId?: string | null }) =>
|
||||
selectExpandedStatus(state, statusId ?? undefined),
|
||||
selectPlainFilters,
|
||||
(_, { warnInsteadOfHide }: { warnInsteadOfHide?: boolean }) =>
|
||||
warnInsteadOfHide,
|
||||
],
|
||||
(status, filters) => {
|
||||
const results: FilterShape[] = [];
|
||||
if (!status || !filters) {
|
||||
return results;
|
||||
}
|
||||
const filtered = status.reblog?.filtered ?? status.filtered;
|
||||
for (const result of filtered) {
|
||||
const filter = filters[result.filter];
|
||||
if (!filter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(filter);
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
);
|
||||
|
||||
export const selectStatusLoadingState = createAppSelector(
|
||||
[
|
||||
(state, { statusId }: { statusId?: string | null }) =>
|
||||
selectExpandedStatus(state, statusId ?? undefined),
|
||||
selectStatusFilters,
|
||||
(_, { warnInsteadOfHide }: { warnInsteadOfHide?: boolean }) =>
|
||||
warnInsteadOfHide,
|
||||
],
|
||||
(status, filters, warnInsteadOfHide) => {
|
||||
if (!status) {
|
||||
return { state: 'not-found', status: null };
|
||||
}
|
||||
|
||||
if (status.isLoading) {
|
||||
return { state: 'loading', status: null };
|
||||
}
|
||||
|
||||
if (
|
||||
!warnInsteadOfHide &&
|
||||
filters.some((filter) => filter.filter_action === 'hide')
|
||||
) {
|
||||
return { state: 'filtered', status: null };
|
||||
}
|
||||
|
||||
return { state: 'loaded', status };
|
||||
},
|
||||
);
|
||||
|
||||
export const selectMediaFilters = createAppSelector(
|
||||
[selectStatusFilters],
|
||||
(filters) =>
|
||||
filters
|
||||
.filter((filter) => filter.filter_action === 'blur')
|
||||
.map((filter) => filter.title),
|
||||
);
|
||||
|
||||
export const getStatusHidden = createAppSelector(
|
||||
[
|
||||
(state, { contextType }: { contextType: StatusContextType }) =>
|
||||
getFilters(state, { contextType }),
|
||||
(state, { id }: { id: string }) =>
|
||||
state.statuses.getIn([id, 'filtered']) as
|
||||
| Immutable.List<FilterResult>
|
||||
| undefined,
|
||||
],
|
||||
(filters, filtered) => {
|
||||
if (!filters) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filtered?.some(
|
||||
(result) =>
|
||||
filters.getIn([result.get('filter'), 'filter_action']) === 'hide',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,8 +8,6 @@ import type {
|
||||
import { createAppSelector } from '@/mastodon/store/typed_functions';
|
||||
|
||||
import { selectIsAccountLocal, selectPlainAccount } from './accounts';
|
||||
import type { FilterShape } from './filters';
|
||||
import { getFilters } from './filters';
|
||||
|
||||
export const getStatusList = createAppSelector(
|
||||
[
|
||||
@@ -20,7 +18,7 @@ export const getStatusList = createAppSelector(
|
||||
);
|
||||
|
||||
export const selectPlainStatus = createAppSelector(
|
||||
[(state, statusId: string) => state.statuses.get(statusId)],
|
||||
[(state, statusId?: string | null) => state.statuses.get(statusId ?? '')],
|
||||
(status) => {
|
||||
if (!status) {
|
||||
return null;
|
||||
@@ -159,6 +157,7 @@ export const selectStatusInteractions = createAppSelector(
|
||||
reply: addAllowed({ isLoggedIn }),
|
||||
report: addAllowed({ isLoggedIn, isNotMine }),
|
||||
revokeQuote: addAllowed({ isQuoted, isNotMine }),
|
||||
translate: addAllowed({ isLoggedIn }),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -202,26 +201,3 @@ export const selectPictureInPicture = createAppSelector(
|
||||
available,
|
||||
}),
|
||||
);
|
||||
|
||||
export const selectMediaMatchFilters = createAppSelector(
|
||||
[
|
||||
(state, { statusId }: { statusId: string }) =>
|
||||
selectPlainStatus(state, statusId),
|
||||
getFilters,
|
||||
],
|
||||
(status, immutableFilters) => {
|
||||
const filters = immutableFilters
|
||||
? (immutableFilters.toJS() as unknown as Record<string, FilterShape>)
|
||||
: null;
|
||||
const mediaFilters: string[] = [];
|
||||
if (status?.filtered && filters) {
|
||||
for (const { filter } of status.filtered) {
|
||||
if (filters[filter]?.filter_action === 'blur') {
|
||||
mediaFilters.push(filters[filter].title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mediaFilters;
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user