mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-28 12:11:45 -05:00
Status redesign: Filters and spoilers (#40550)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
105
app/javascript/mastodon/components/status/warning.tsx
Normal file
105
app/javascript/mastodon/components/status/warning.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user