Status redesign: Filters and spoilers (#40550)

This commit is contained in:
Echo
2026-09-16 12:33:10 +00:00
committed by GitHub
parent a65605d747
commit bcc30bfdd7
8 changed files with 241 additions and 85 deletions

View File

@@ -1,4 +1,3 @@
import type React from 'react';
import { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
@@ -51,12 +50,10 @@ export const StatusContent: React.FC<
const maxHeight = lineHeightPx * MAX_LINES;
setCollapsed(
(node.clientHeight > maxHeight ||
node.scrollWidth > node.clientWidth) &&
!status.spoiler_text,
node.clientHeight > maxHeight || node.scrollWidth > node.clientWidth,
);
},
[collapsed, status.spoiler_text],
[collapsed],
);
const htmlHandlers = useHandlersForStatus(status);

View File

@@ -56,7 +56,7 @@ export function useStatusHandlers({
contextType?: StatusContextType;
onOpen?: () => void;
}) {
const matchedFilters = useAppSelector((state) =>
const { filterAction } = useAppSelector((state) =>
selectStatusFilters(state, { contextType, statusId: status?.id }),
);
const [showDespiteFilter, { onToggle: onFilterToggle }] = useToggle(false);
@@ -65,28 +65,18 @@ export function useStatusHandlers({
const statusId = status?.id;
// Display handlers
const onExpandedToggle = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId));
}, [dispatch, statusId]);
const onToggleHidden = useCallback(() => {
if (!status) {
return;
}
if (!matchedFilters.length || showDespiteFilter) {
if (!filterAction || showDespiteFilter) {
dispatch(toggleStatusSpoilers(status.id));
}
if (!status.hidden || !status.spoiler_text) {
onFilterToggle();
}
}, [
dispatch,
matchedFilters.length,
onFilterToggle,
showDespiteFilter,
status,
]);
}, [dispatch, filterAction, onFilterToggle, showDespiteFilter, status]);
// Interaction handlers
const handlerFactory = useCallback(
@@ -185,9 +175,9 @@ export function useStatusHandlers({
return useMemo(
() => ({
isFiltered: !!filterAction,
showDespiteFilter,
onOpenClick,
onExpandedToggle,
onFilterToggle,
onMention,
onOpen: () => {
@@ -203,8 +193,8 @@ export function useStatusHandlers({
onTranslate: handlerFactory('translate'),
}),
[
filterAction,
handlerFactory,
onExpandedToggle,
onFilterToggle,
onMention,
onOpenCallback,

View File

@@ -1,15 +1,12 @@
import { useMemo } from 'react';
import { useId, useMemo } from 'react';
import classNames from 'classnames';
import type { Merge } from 'type-fest';
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 } from '../hashtag_bar';
import { Hotkeys } from '../hotkeys';
import { Poll } from '../poll';
@@ -30,6 +27,7 @@ import { StatusRedesignHeader } from './redesign/header';
import classes from './styles.module.scss';
import { TranslateButton } from './translate';
import type { StatusContainerProps, StatusContextType } from './types';
import { StatusWarning } from './warning';
type StatusRedesignProps = Merge<
Omit<StatusContainerProps, 'account'>,
@@ -83,9 +81,6 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
const { status, parent } = useAppSelector((state) =>
selectStatusReblog(state, id),
);
const matchedFilters = useAppSelector((state) =>
selectStatusFilters(state, { contextType, statusId: parent?.id ?? id }),
);
const statusId = status?.id;
// Display
@@ -99,15 +94,20 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
status ? computeHashtagBarForStatus(status) : {},
[status],
);
const contentWrapperId = useId();
// Handlers
const {
isFiltered,
showDespiteFilter,
onExpandedToggle,
onFilterToggle,
onTranslate,
...handlers
} = useStatusHandlers({ status, contextType, onOpen });
} = useStatusHandlers({
status,
contextType,
onOpen,
});
if (!status) {
return null; // loading state
@@ -115,10 +115,6 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
const actualStatus = parent ?? status;
const expanded =
(matchedFilters.length === 0 || showDespiteFilter) &&
(!status.hidden || !status.spoiler_text);
const hotkeysProps = {
handlers: {
...handlers,
@@ -129,21 +125,20 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
'data-id': id,
};
const isHidden =
(!showDespiteFilter && isFiltered) ||
(!!status.spoiler_text && status.hidden);
if (hidden) {
return (
<StatusHotkeys {...hotkeysProps}>
<span>{status.account.display_name || status.account.username}</span>
{status.spoiler_text && <span>{status.spoiler_text}</span>}
{expanded && <span>{status.content}</span>}
{!isHidden && <span>{status.content}</span>}
</StatusHotkeys>
);
}
const showFooter =
(expanded && hashtagsInBar.length > 0) ||
variant === 'page' ||
(showActions && !isQuotedPost);
return (
<StatusContext.Provider value={{ id, contextType }}>
<StatusHotkeys
@@ -170,25 +165,23 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
{headerContents}
</StatusRedesignHeader>
{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}
/>
)}
<TranslateButton status={status} onTranslate={onTranslate} />
{expanded && (
<StatusWarning
statusId={actualStatus.id}
dismissedFilter={showDespiteFilter}
onFilterToggle={onFilterToggle}
wrapperId={contentWrapperId}
/>
<div
className={classNames(
classes.contentWrapper,
isHidden && classes.isFiltered,
)}
id={contentWrapperId}
inert={isHidden}
>
<StatusContent
status={status}
statusContent={statusContent}
@@ -209,17 +202,15 @@ export const StatusRedesign: React.FC<StatusRedesignProps> = ({
{children}
</StatusContent>
)}
{showFooter && (
<StatusHashtagBar
hashtags={hashtagsInBar}
accountId={status.account.id}
/>
</div>
{(variant === 'page' || (showActions && !isQuotedPost)) && (
<footer className={classes.footer}>
{expanded && hashtagsInBar.length > 0 && (
<StatusHashtagBar
hashtags={hashtagsInBar}
accountId={status.account.id}
/>
)}
{variant === 'page' && (
<StatusMeta status={status} className={classes.meta} />
)}
@@ -244,6 +235,7 @@ interface StatusHotkeysProps {
children: React.ReactNode;
handlers: Omit<
StatusHandlers,
| 'isFiltered'
| 'showDespiteFilter'
| 'onOpenClick'
| 'onHeaderClick'

View File

@@ -32,16 +32,20 @@
--content-col: span 2;
}
.isFiltered {
content: '';
}
.header {
grid-column: span 2;
}
.content,
.footer,
.root :global(.status__prepend),
.root :global(.content-warning),
.spoiler,
.translate,
.contentReadMore {
.contentWrapper,
.contentReadMore,
.footer {
grid-column: var(--content-col);
}
@@ -57,12 +61,72 @@
margin-inline-start: auto;
}
.content {
--mask-height: 80px;
.spoiler {
display: flex;
align-items: center;
}
.spoilerContent {
@include mixins.type-body-strong;
font-style: italic;
flex-grow: 1;
}
.contentWrapper,
.content {
display: flex;
flex-direction: column;
gap: var(--space-xs);
position: relative;
}
.contentWrapper {
--fade-time: 100ms;
overflow: clip;
border-radius: var(--radius-xs);
&::after {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(
var(--color-bg-highlight) 1px,
transparent 0
);
background-size: var(--space-xs) var(--space-xs);
background-repeat: repeat;
// See: https://css-tricks.com/snippets/css/toggle-visibility-when-hiding-elements/
visibility: hidden;
opacity: 0;
transition:
visibility 0s linear var(--fade-time),
opacity var(--fade-time) ease-in-out;
// Show only if backdrop-filter is unsupported.
background-color: var(--color-bg-blend);
@supports (backdrop-filter: blur(20px)) {
background-color: transparent;
backdrop-filter: blur(20px);
}
}
&.isFiltered::after {
visibility: visible;
opacity: 1;
transition:
visibility 0s linear 0s,
opacity var(--fade-time) ease-in-out;
}
}
.content {
--mask-height: 80px;
overflow-y: clip;
&.collapsed {
max-height: var(--max-height); // Set via JS.

View File

@@ -0,0 +1,105 @@
import { useCallback } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { toggleStatusSpoilers } from '@/mastodon/actions/statuses';
import { selectStatusFilters } from '@/mastodon/selectors/filters';
import { selectPlainStatus } from '@/mastodon/selectors/statuses';
import {
useAppDispatch,
useAppSelector,
} from '@/mastodon/store/typed_functions';
import { Button } from '../button/redesign';
import { EmojiHTML } from '../emoji/html';
import { useStatusContext } from './hooks';
import classes from './styles.module.scss';
export const StatusWarning: React.FC<{
statusId: string;
dismissedFilter: boolean;
onFilterToggle: () => void;
wrapperId?: string;
}> = ({ statusId, dismissedFilter, onFilterToggle, wrapperId }) => {
const { contextType } = useStatusContext();
const { filters, filterAction } = useAppSelector((state) =>
selectStatusFilters(state, { contextType, statusId }),
);
const intl = useIntl();
const status = useAppSelector((state) => selectPlainStatus(state, statusId));
const dispatch = useAppDispatch();
const onToggleCW = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId));
}, [dispatch, statusId]);
if (!status || (!filterAction && !status.spoiler_text)) {
return null;
}
const spoilerText = status.translation?.spoilerHtml ?? status.spoilerHtml;
return (
<>
{!!filterAction && (
<StatusWarningDisplay
hidden={dismissedFilter}
onToggle={onFilterToggle}
wrapperId={wrapperId}
>
<FormattedMessage
id='filter_warning.matches_filter'
defaultMessage='Matches filter “<span>{title}</span>”'
values={{
title: intl.formatList(filters.map(({ title }) => title)),
span: (chunks) => <span>{chunks}</span>,
}}
/>
</StatusWarningDisplay>
)}
{!!spoilerText && (!filterAction || dismissedFilter) && (
<StatusWarningDisplay
hidden={!status.hidden}
onToggle={onToggleCW}
wrapperId={wrapperId}
>
<EmojiHTML htmlString={spoilerText} extraEmojis={status.emojis} />
</StatusWarningDisplay>
)}
</>
);
};
const StatusWarningDisplay: React.FC<{
hidden: boolean;
onToggle: () => void;
children: React.ReactElement;
wrapperId?: string;
}> = ({ hidden, onToggle, children, wrapperId }) => {
return (
<div className={classes.spoiler}>
<div className={classes.spoilerContent}>{children}</div>
<Button
variant='solid'
size='sm'
onClick={onToggle}
aria-controls={wrapperId}
>
{hidden ? (
<FormattedMessage
id='content_warning.hide_short'
defaultMessage='Hide'
/>
) : (
<FormattedMessage
id='content_warning.show_short'
defaultMessage='Show'
/>
)}
</Button>
</div>
);
};

View File

@@ -673,6 +673,7 @@
"confirmations.withdraw_request.confirm": "Withdraw request",
"confirmations.withdraw_request.title": "Withdraw request to follow {name}?",
"content_warning.hide": "Hide post",
"content_warning.hide_short": "Hide",
"content_warning.show": "Show anyway",
"content_warning.show_more": "Show more",
"content_warning.show_short": "Show",

View File

@@ -54,14 +54,20 @@ export const selectStatusFilters = createAppSelector(
(state, { statusId }: { statusId?: string | null }) =>
selectExpandedStatus(state, statusId ?? undefined),
selectPlainFilters,
(_, { warnInsteadOfHide }: { warnInsteadOfHide?: boolean }) =>
warnInsteadOfHide,
(state) => state.meta.get('me') as string | undefined,
(_, { contextType }: { contextType?: StatusContextType }) => contextType,
],
(status, filters) => {
(status, filters, currentAccountId, contextType) => {
const results: FilterShape[] = [];
if (!status || !filters) {
return results;
let filterAction: 'warn' | 'hide' | null = null;
if (!status || !filters || status.account.acct === currentAccountId) {
return { filters: results, filterAction };
}
const warnInsteadOfHide =
!!contextType &&
['detailed', 'bookmarks', 'favourites', 'search'].includes(contextType);
const filtered = status.reblog?.filtered ?? status.filtered;
for (const result of filtered) {
const filter = filters[result.filter];
@@ -69,16 +75,22 @@ export const selectStatusFilters = createAppSelector(
continue;
}
if (filter.filter_action === 'hide' && !warnInsteadOfHide) {
filterAction = 'hide';
} else {
filterAction ??= 'warn';
}
results.push(filter);
}
return results;
return { filters: results, filterAction };
},
);
export const selectMediaFilters = createAppSelector(
[selectStatusFilters],
(filters) =>
({ filters }) =>
filters
.filter((filter) => filter.filter_action === 'blur')
.map((filter) => filter.title),

View File

@@ -78,10 +78,8 @@ export const selectStatusLoadingState = createAppSelector(
(state, { statusId }: { statusId?: string | null }) =>
selectExpandedStatus(state, statusId ?? undefined),
selectStatusFilters,
(_, { warnInsteadOfHide }: { warnInsteadOfHide?: boolean }) =>
warnInsteadOfHide,
],
(status, filters, warnInsteadOfHide) => {
(status, { filterAction }) => {
if (!status) {
return { state: 'not-found', status: null } as const;
}
@@ -90,10 +88,7 @@ export const selectStatusLoadingState = createAppSelector(
return { state: 'loading', status: null } as const;
}
if (
!warnInsteadOfHide &&
filters.some((filter) => filter.filter_action === 'hide')
) {
if (filterAction === 'hide') {
return { state: 'filtered', status: null } as const;
}