pl-fe: support filters blur action

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-08-13 09:52:47 +02:00
parent 38d155d7a3
commit 122a2a42c1
12 changed files with 87 additions and 45 deletions

View File

@ -35,12 +35,12 @@ const fetchFilter = (filterId: string) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).filtering.getFilter(filterId);
const createFilter = (title: string, expires_in: number | undefined, context: Array<FilterContext>, hide: boolean, keywords_attributes: FilterKeywords) =>
const createFilter = (title: string, expires_in: number | undefined, context: Array<FilterContext>, filter_action: Filter['filter_action'], keywords_attributes: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).filtering.createFilter({
title,
context,
filter_action: hide ? 'hide' : 'warn',
filter_action,
expires_in,
keywords_attributes,
}).then(response => {
@ -49,12 +49,12 @@ const createFilter = (title: string, expires_in: number | undefined, context: Ar
return response;
});
const updateFilter = (filterId: string, title: string, expires_in: number | undefined, context: Array<FilterContext>, hide: boolean, keywords_attributes: FilterKeywords) =>
const updateFilter = (filterId: string, title: string, expires_in: number | undefined, context: Array<FilterContext>, filter_action: Filter['filter_action'], keywords_attributes: FilterKeywords) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).filtering.updateFilter(filterId, {
title,
context,
filter_action: hide ? 'hide' : 'warn',
filter_action,
expires_in,
keywords_attributes,
}).then(response => {

View File

@ -10,7 +10,7 @@ import type { MediaAttachment } from 'pl-api';
import type { Status } from 'pl-fe/normalizers/status';
interface IAttachmentThumbs {
status: Pick<Status, 'media_attachments' | 'sensitive'>;
status: Pick<Status, 'filtered' | 'media_attachments' | 'sensitive'>;
onClick?(): void;
}
@ -21,7 +21,7 @@ const AttachmentThumbs = ({ status, onClick }: IAttachmentThumbs) => {
const fallback = <div className='media-gallery--compact' />;
const onOpenMedia = (media: Array<MediaAttachment>, index: number) => openModal('MEDIA', { media, index });
const visible = useMediaVisible(status, displayMedia);
const [visible] = useMediaVisible(status, displayMedia);
return (
<div className='relative'>

View File

@ -14,7 +14,7 @@ import type { Status } from 'pl-fe/normalizers/status';
interface IStatusMedia {
/** Status entity to render media for. */
status: Pick<Status, 'id' | 'account' | 'card' | 'expectsCard' | 'media_attachments' | 'quote_id' | 'sensitive' | 'visibility'>;
status: Pick<Status, 'id' | 'account' | 'card' | 'expectsCard' | 'filtered' | 'media_attachments' | 'quote_id' | 'sensitive' | 'visibility'>;
/** Whether to display compact media. */
muted?: boolean;
/** Callback when compact media is clicked. */
@ -30,7 +30,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
const { openModal } = useModalsStore();
const { displayMedia } = useSettings();
const visible = useMediaVisible(status, displayMedia);
const [visible] = useMediaVisible(status, displayMedia);
const size = status.media_attachments.length;
const firstAttachment = status.media_attachments[0];

View File

@ -97,7 +97,8 @@ const Status: React.FC<IStatus> = (props) => {
const statusUrl = `/@${actualStatus.account.acct}/posts/${actualStatus.id}`;
const group = actualStatus.group;
const filtered = (status.filtered?.length || actualStatus.filtered?.length) > 0;
const filterResults = useMemo(() => [...status.filtered, ...actualStatus.filtered].filter(({ filter }) => filter.filter_action === 'warn'), [status.filtered, actualStatus.filtered]);
const filtered = filterResults.length > 0;
// Track height changes we know about to compensate scrolling.
useEffect(() => {
@ -336,7 +337,7 @@ const Status: React.FC<IStatus> = (props) => {
<HotKeys handlers={minHandlers} attachRef={node}>
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {status.filtered.join(', ')}.
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {filterResults.map(({ filter }) => filter.title).join(', ')}.
{' '}
<button className='text-primary-600 hover:underline dark:text-accent-blue' onClick={handleUnfilter}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />

View File

@ -1,5 +1,5 @@
import clsx from 'clsx';
import React from 'react';
import React, { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from 'pl-fe/components/ui/button';
@ -8,23 +8,30 @@ import Text from 'pl-fe/components/ui/text';
import { useSettings } from 'pl-fe/hooks/use-settings';
import { useStatusMetaStore } from 'pl-fe/stores/status-meta';
import type { FilterResult } from 'pl-api';
import type { Status } from 'pl-fe/normalizers/status';
const useMediaVisible = (status: Pick<Status, 'media_attachments' | 'sensitive'> & { id?: string }, displayMedia: 'default' | 'show_all' | 'hide_all') => {
let visible = !status.sensitive;
const useMediaVisible = (status: Pick<Status, 'filtered' | 'media_attachments' | 'sensitive'> & { id?: string }, displayMedia: 'default' | 'show_all' | 'hide_all'): [boolean, Array<FilterResult>] => {
const statusesMeta = useStatusMetaStore().statuses;
const mediaVisible = status.id ? statusesMeta[status.id]?.mediaVisible : undefined;
if (mediaVisible !== undefined) visible = mediaVisible;
else if (displayMedia === 'show_all') visible = true;
else if (displayMedia === 'hide_all' && status.media_attachments.length) visible = false;
return useMemo(() => {
let visible = !status.sensitive;
return visible;
const filterResults = status.filtered.filter(({ filter }) => filter.filter_action === 'blur');
if (filterResults.length) return [mediaVisible !== undefined ? mediaVisible : false, filterResults];
if (mediaVisible !== undefined) visible = mediaVisible;
else if (displayMedia === 'show_all') visible = true;
else if (displayMedia === 'hide_all' && status.media_attachments.length) visible = false;
return [visible, []];
}, [status.sensitive, status.filtered, mediaVisible]);
};
const useShowOverlay = (status: Pick<Status, 'id' | 'media_attachments' | 'sensitive'>, displayMedia: 'default' | 'show_all' | 'hide_all') => {
const visible = useMediaVisible(status, displayMedia);
const useShowOverlay = (status: Pick<Status, 'id' | 'filtered' | 'media_attachments' | 'sensitive'>, displayMedia: 'default' | 'show_all' | 'hide_all') => {
const [visible] = useMediaVisible(status, displayMedia);
const showHideButton = status.sensitive || (status.media_attachments.length && displayMedia === 'hide_all');
@ -40,10 +47,11 @@ const messages = defineMessages({
sensitiveTitle: { id: 'status.sensitive_warning', defaultMessage: 'Sensitive content' },
sensitiveSubtitle: { id: 'status.sensitive_warning.subtitle', defaultMessage: 'This content may not be suitable for all audiences.' },
show: { id: 'moderation_overlay.show', defaultMessage: 'Show content' },
matchesFilter: { id: 'status.sensitive_warning.matches_filter', defaultMessage: 'Matches filter “<span>{title}</span>”' },
});
interface ISensitiveContentOverlay {
status: Pick<Status, 'id' | 'sensitive' | 'media_attachments'>;
status: Pick<Status, 'id' | 'filtered' | 'sensitive' | 'media_attachments'>;
}
const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveContentOverlay>((props, ref) => {
@ -52,7 +60,9 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
const intl = useIntl();
const { displayMedia } = useSettings();
const visible = useMediaVisible(status, displayMedia);
const [visible, filters] = useMediaVisible(status, displayMedia);
const matchedFilters = useMemo(() => filters.map(({ filter }) => filter.title), [filters]);
const { hideStatusMedia, revealStatusMedia } = useStatusMetaStore();
@ -91,7 +101,10 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
</Text>
<Text theme='white' size='sm' weight='medium'>
{intl.formatMessage(messages.sensitiveSubtitle)}
{filters.length ? intl.formatMessage(messages.matchesFilter, {
title: matchedFilters.join(', '),
span: (chunks) => <span className='filter-name'>{chunks}</span>,
}) : intl.formatMessage(messages.sensitiveSubtitle)}
</Text>
</div>

View File

@ -12,7 +12,7 @@ import type { Status } from 'pl-fe/normalizers/status';
interface IReplyIndicator {
className?: string;
status?: Pick<Status, 'account_id' | 'content' | 'created_at' | 'emojis' | 'media_attachments' | 'mentions' | 'search_index' | 'sensitive' | 'spoiler_text' | 'quote_id'>;
status?: Pick<Status, 'account_id' | 'content' | 'created_at' | 'emojis' | 'filtered' | 'media_attachments' | 'mentions' | 'search_index' | 'sensitive' | 'spoiler_text' | 'quote_id'>;
onCancel?: () => void;
hideActions: boolean;
}

View File

@ -428,6 +428,11 @@
"column.filters.expiration.86400": "1 day",
"column.filters.expiration.never": "Never",
"column.filters.expires": "Expire after",
"column.filters.filter_action_blur": "Hide media with a warning",
"column.filters.filter_action_header": "Filter action",
"column.filters.filter_action_hide": "Hide completely",
"column.filters.filter_action_hint": "Choose which action to perform when a post matches the filter",
"column.filters.filter_action_warn": "Hide with a warning",
"column.filters.hide_header": "Hide completely",
"column.filters.hide_hint": "Completely hide the filtered content, instead of showing a warning",
"column.filters.home_timeline": "Home timeline",
@ -879,6 +884,7 @@
"filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply",
"filters.create_filter": "Create filter",
"filters.filters_list_blur": "Hide media with a warning",
"filters.filters_list_context_label": "Filter contexts:",
"filters.filters_list_drop": "Drop",
"filters.filters_list_expired": "Expired",
@ -1683,6 +1689,7 @@
"status.reply_all": "Reply to thread",
"status.report": "Report @{name}",
"status.sensitive_warning": "Sensitive content",
"status.sensitive_warning.matches_filter": "Matches filter “<span>{title}</span>”",
"status.sensitive_warning.subtitle": "This content may not be suitable for all audiences.",
"status.share": "Share",
"status.show_filter_reason": "Show anyway",

View File

@ -13,6 +13,7 @@ import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useStatusHistory } from 'pl-fe/queries/statuses/use-status-history';
import type { BaseModalProps } from 'pl-fe/features/ui/components/modal-root';
import type { Status } from 'pl-fe/normalizers/status';
interface CompareHistoryModalProps {
statusId: string;
@ -75,7 +76,7 @@ const CompareHistoryModal: React.FC<BaseModalProps & CompareHistoryModalProps> =
)}
{version.media_attachments.length > 0 && (
<AttachmentThumbs status={version} />
<AttachmentThumbs status={version as Status} />
)}
<Text align='right' tag='span' theme='muted' size='sm'>

View File

@ -121,7 +121,6 @@ const normalizeStatus = (status: BaseStatus & {
account: normalizeAccount(status.account),
accounts: status.accounts?.map(normalizeAccount),
mentions,
filtered: status.filtered?.map(result => result.filter.title),
event,
group,
media_attachments,

View File

@ -1,3 +1,4 @@
import { Filter, type FilterContext } from 'pl-api';
import React, { useEffect, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
@ -12,6 +13,7 @@ import FormActions from 'pl-fe/components/ui/form-actions';
import FormGroup from 'pl-fe/components/ui/form-group';
import HStack from 'pl-fe/components/ui/hstack';
import Input from 'pl-fe/components/ui/input';
import Select from 'pl-fe/components/ui/select';
import Stack from 'pl-fe/components/ui/stack';
import Streamfield from 'pl-fe/components/ui/streamfield';
import Text from 'pl-fe/components/ui/text';
@ -21,7 +23,6 @@ import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useFeatures } from 'pl-fe/hooks/use-features';
import toast from 'pl-fe/toast';
import type { FilterContext } from 'pl-api';
import type { StreamfieldComponent } from 'pl-fe/components/ui/streamfield';
interface IFilterField {
@ -50,6 +51,11 @@ const messages = defineMessages({
drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' },
hide_header: { id: 'column.filters.hide_header', defaultMessage: 'Hide completely' },
hide_hint: { id: 'column.filters.hide_hint', defaultMessage: 'Completely hide the filtered content, instead of showing a warning' },
filter_action_header: { id: 'column.filters.filter_action_header', defaultMessage: 'Filter action' },
filter_action_hint: { id: 'column.filters.filter_action_hint', defaultMessage: 'Choose which action to perform when a post matches the filter' },
filter_action_warn: { id: 'column.filters.filter_action_warn', defaultMessage: 'Hide with a warning' },
filter_action_blur: { id: 'column.filters.filter_action_blur', defaultMessage: 'Hide media with a warning' },
filter_action_hide: { id: 'column.filters.filter_action_hide', defaultMessage: 'Hide completely' },
add_new: { id: 'column.filters.add_new', defaultMessage: 'Add new filter' },
edit: { id: 'column.filters.edit', defaultMessage: 'Edit filter' },
create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' },
@ -107,7 +113,7 @@ const EditFilterPage: React.FC<IEditFilter> = ({ params }) => {
const [notifications, setNotifications] = useState(false);
const [conversations, setConversations] = useState(false);
const [accounts, setAccounts] = useState(false);
const [hide, setHide] = useState(false);
const [filterAction, setFilterAction] = useState<Filter['filter_action']>('warn');
const [keywords, setKeywords] = useState<IFilterField[]>([{ keyword: '', whole_word: false }]);
const expirations = useMemo(() => ({
@ -145,8 +151,8 @@ const EditFilterPage: React.FC<IEditFilter> = ({ params }) => {
}
dispatch(params.id
? updateFilter(params.id, title, expiresIn, context, hide, keywords)
: createFilter(title, expiresIn, context, hide, keywords)).then(() => {
? updateFilter(params.id, title, expiresIn, context, filterAction, keywords)
: createFilter(title, expiresIn, context, filterAction, keywords)).then(() => {
history.push('/filters');
}).catch(() => {
toast.error(intl.formatMessage(messages.create_error));
@ -172,7 +178,7 @@ const EditFilterPage: React.FC<IEditFilter> = ({ params }) => {
setNotifications(filter.context.includes('notifications'));
setConversations(filter.context.includes('thread'));
setAccounts(filter.context.includes('account'));
setHide(filter.filter_action === 'hide');
setFilterAction(filter.filter_action);
setKeywords(filter.keywords);
} else {
setNotFound(true);
@ -265,15 +271,28 @@ const EditFilterPage: React.FC<IEditFilter> = ({ params }) => {
</List>
<List>
<ListItem
label={intl.formatMessage(features.filtersV2 ? messages.hide_header : messages.drop_header)}
hint={intl.formatMessage(features.filtersV2 ? messages.hide_hint : messages.drop_hint)}
>
<Toggle
checked={hide}
onChange={({ target }) => setHide(target.checked)}
/>
</ListItem>
{features.filtersV2BlurAction ? (
<ListItem
label={intl.formatMessage(messages.filter_action_header)}
hint={intl.formatMessage(messages.filter_action_hint)}
>
<Select value={filterAction} onChange={({ target }) => setFilterAction(target.value as Filter['filter_action'])}>
<option value='warn'>{intl.formatMessage(messages.filter_action_warn)}</option>
<option value='hide'>{intl.formatMessage(messages.filter_action_hide)}</option>
<option value='blur'>{intl.formatMessage(messages.filter_action_blur)}</option>
</Select>
</ListItem>
) : (
<ListItem
label={intl.formatMessage(features.filtersV2 ? messages.hide_header : messages.drop_header)}
hint={intl.formatMessage(features.filtersV2 ? messages.hide_hint : messages.drop_hint)}
>
<Toggle
checked={filterAction === 'hide'}
onChange={({ target }) => setFilterAction(target.checked ? 'hide' : 'warn')}
/>
</ListItem>
)}
</List>
{features.filtersV2 && keywordsField}

View File

@ -93,7 +93,9 @@ const FiltersPage = () => {
{filtersV2 ? (
filter.filter_action === 'hide' ?
<FormattedMessage id='filters.filters_list_hide_completely' defaultMessage='Hide content' /> :
<FormattedMessage id='filters.filters_list_warn' defaultMessage='Display warning' />
filter.filter_action === 'blur' ?
<FormattedMessage id='filters.filters_list_blur' defaultMessage='Hide media with a warning' /> :
<FormattedMessage id='filters.filters_list_warn' defaultMessage='Display warning' />
) : (filter.filter_action === 'hide' ?
<FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> :
<FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />)}

View File

@ -8,7 +8,7 @@ import { validId } from 'pl-fe/utils/auth';
import ConfigDB from 'pl-fe/utils/config-db';
import { shouldFilter } from 'pl-fe/utils/timelines';
import type { Filter, NotificationGroup, Relationship } from 'pl-api';
import type { Filter, FilterResult, NotificationGroup, Relationship } from 'pl-api';
import type { EntityStore } from 'pl-fe/entity-store/types';
import type { Account } from 'pl-fe/normalizers/account';
import type { Group } from 'pl-fe/normalizers/group';
@ -100,8 +100,8 @@ const regexFromFilters = (filters: Array<Filter>) => {
};
const checkFiltered = (index: string, filters: Array<Filter>) =>
filters.reduce((result: Array<string>, filter) =>
result.concat(filter.keywords.reduce((result: Array<string>, keyword) => {
filters.reduce((result: Array<FilterResult>, filter) =>
result.concat(filter.keywords.reduce((result: Array<FilterResult>, keyword) => {
let expr = escapeRegExp(keyword.keyword);
if (keyword.whole_word) {
@ -116,7 +116,7 @@ const checkFiltered = (index: string, filters: Array<Filter>) =>
const regex = new RegExp(expr);
if (regex.test(index)) return result.concat(filter.title);
if (regex.test(index)) return result.concat({ filter, keyword_matches: null, status_matches: null });
return result;
}, [])), []);