From 335910611cbcb49d732d71d9a429e6c048d1c4aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 1 Mar 2026 20:44:27 +0100 Subject: [PATCH] nicolium: reintroduce status filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../src/components/statuses/status-list.tsx | 7 ++- .../src/components/statuses/status.tsx | 15 ++++- .../src/containers/status-container.tsx | 5 +- .../status/components/thread-status.tsx | 4 +- .../containers/quoted-status-container.tsx | 2 +- .../src/pages/accounts/account-timeline.tsx | 1 + .../src/queries/settings/use-filters.ts | 20 +++++-- .../src/queries/statuses/use-status.ts | 30 +++++++++- packages/nicolium/src/selectors/index.ts | 60 +------------------ packages/nicolium/src/utils/filters.ts | 58 ++++++++++++++++++ 10 files changed, 126 insertions(+), 76 deletions(-) create mode 100644 packages/nicolium/src/utils/filters.ts diff --git a/packages/nicolium/src/components/statuses/status-list.tsx b/packages/nicolium/src/components/statuses/status-list.tsx index a3cd2792a..eb60c15e5 100644 --- a/packages/nicolium/src/components/statuses/status-list.tsx +++ b/packages/nicolium/src/components/statuses/status-list.tsx @@ -8,6 +8,7 @@ import ScrollableList, { type IScrollableList } from '@/components/scrollable-li import StatusContainer from '@/containers/status-container'; import PlaceholderStatus from '@/features/placeholder/components/placeholder-status'; import PendingStatus from '@/features/ui/components/pending-status'; +import { timelineToFilterContextType } from '@/queries/settings/use-filters'; import { selectChild } from '@/utils/scroll-utils'; import Icon from '../ui/icon'; @@ -66,6 +67,8 @@ const StatusList: React.FC = ({ }) => { const node = useRef(null); + const contextType = timelineToFilterContextType(timelineId); + const getFeaturedStatusCount = () => featuredStatusIds?.length ?? 0; const getCurrentStatusIndex = (id: string, featured: boolean): number => { @@ -139,7 +142,7 @@ const StatusList: React.FC = ({ id={statusId} onMoveUp={handleMoveUp} onMoveDown={handleMoveDown} - contextType={timelineId} + contextType={contextType} showGroup={showGroup} variant='slim' fromBookmarks={other.scrollKey === 'bookmarked_statuses'} @@ -164,7 +167,7 @@ const StatusList: React.FC = ({ featured onMoveUp={handleMoveUp} onMoveDown={handleMoveDown} - contextType={timelineId} + contextType={contextType} showGroup={showGroup} variant='slim' /> diff --git a/packages/nicolium/src/components/statuses/status.tsx b/packages/nicolium/src/components/statuses/status.tsx index f83f04ee4..de286cfea 100644 --- a/packages/nicolium/src/components/statuses/status.tsx +++ b/packages/nicolium/src/components/statuses/status.tsx @@ -40,6 +40,8 @@ import StatusReactionsBar from './status-reactions-bar'; import StatusReplyMentions from './status-reply-mentions'; import Tombstone from './tombstone'; +import type { FilterContextType } from '@/queries/settings/use-filters'; + const messages = defineMessages({ edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, rebloggedBy: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, @@ -154,6 +156,7 @@ interface IStatus { status: SelectedStatus; onClick?: () => void; muted?: boolean; + contextType?: FilterContextType; unread?: boolean; onMoveUp?: (statusId: string, featured?: boolean) => void; onMoveDown?: (statusId: string, featured?: boolean) => void; @@ -188,6 +191,7 @@ const Status: React.FC = (props) => { fromBookmarks = false, fromHomeTimeline = false, className, + contextType, } = props; const intl = useIntl(); @@ -202,7 +206,8 @@ const Status: React.FC = (props) => { const didShowCard = useRef(false); const node = useRef(null); - const actualStatus = useStatus(status.reblog_id || undefined).data || status; + const actualStatus = + useStatus(status.reblog_id || undefined, { withFilteredResults: true }).data || status; const { data: group } = useGroupQuery(actualStatus.group_id ?? undefined); @@ -214,8 +219,12 @@ const Status: React.FC = (props) => { const isReblog = status.reblog_id; const filterResults = useMemo(() => { + if (!contextType) return []; + return [...status.filtered, ...actualStatus.filtered] - .filter(({ filter }) => filter.filter_action === 'warn') + .filter( + ({ filter }) => filter.filter_action === 'warn' && filter.context.includes(contextType), + ) .reduce( (uniqueFilters, current) => { if ( @@ -227,7 +236,7 @@ const Status: React.FC = (props) => { }, [] as typeof status.filtered, ); - }, [status.filtered, actualStatus.filtered]); + }, [status.filtered, actualStatus.filtered, contextType]); const filtered = filterResults.length > 0; // Track height changes we know about to compensate scrolling. diff --git a/packages/nicolium/src/containers/status-container.tsx b/packages/nicolium/src/containers/status-container.tsx index 8db8e3a43..5a3909dc6 100644 --- a/packages/nicolium/src/containers/status-container.tsx +++ b/packages/nicolium/src/containers/status-container.tsx @@ -5,7 +5,6 @@ import { useStatus } from '@/queries/statuses/use-status'; interface IStatusContainer extends Omit { id: string; - contextType?: string; /** @deprecated Unused. */ otherAccounts?: any; } @@ -15,9 +14,9 @@ interface IStatusContainer extends Omit { * @deprecated Use the Status component directly. */ const StatusContainer: React.FC = (props) => { - const { id, contextType: _contextType } = props; + const { id } = props; - const { data: status } = useStatus(id); + const { data: status } = useStatus(id, { withFilteredResults: true }); if (status) { return ; diff --git a/packages/nicolium/src/features/status/components/thread-status.tsx b/packages/nicolium/src/features/status/components/thread-status.tsx index 138df46b2..e42e7834f 100644 --- a/packages/nicolium/src/features/status/components/thread-status.tsx +++ b/packages/nicolium/src/features/status/components/thread-status.tsx @@ -7,9 +7,11 @@ import PlaceholderStatus from '@/features/placeholder/components/placeholder-sta import { useMinimalStatus } from '@/queries/statuses/use-status'; import { useReplyCount, useReplyToId } from '@/stores/contexts'; +import type { FilterContextType } from '@/queries/settings/use-filters'; + interface IThreadStatus { id: string; - contextType?: string; + contextType?: FilterContextType; focusedStatusId: string; onMoveUp: (id: string) => void; onMoveDown: (id: string) => void; diff --git a/packages/nicolium/src/features/status/containers/quoted-status-container.tsx b/packages/nicolium/src/features/status/containers/quoted-status-container.tsx index 64974f57d..03552289d 100644 --- a/packages/nicolium/src/features/status/containers/quoted-status-container.tsx +++ b/packages/nicolium/src/features/status/containers/quoted-status-container.tsx @@ -9,7 +9,7 @@ interface IQuotedStatusContainer { } const QuotedStatusContainer: React.FC = ({ statusId }) => { - const { data: status } = useStatus(statusId); + const { data: status } = useStatus(statusId, { withFilteredResults: true }); if (!status) { return null; diff --git a/packages/nicolium/src/pages/accounts/account-timeline.tsx b/packages/nicolium/src/pages/accounts/account-timeline.tsx index 8c4bae156..947e54260 100644 --- a/packages/nicolium/src/pages/accounts/account-timeline.tsx +++ b/packages/nicolium/src/pages/accounts/account-timeline.tsx @@ -85,6 +85,7 @@ const AccountTimelinePage: React.FC = () => { return ( (select: (data: Array) => T): UseQueryResult; function useFilters(): UseQueryResult, Error>; function useFilters>(select?: (data: Array) => T) { @@ -34,29 +36,35 @@ function useFilters>(select?: (data: Array) => T) { }); } -const toServerSideType = (columnType: string): Filter['context'][0] => { +const timelineToFilterContextType = (columnType?: string): FilterContextType => { switch (columnType) { + case undefined: + return 'public'; case 'home': case 'notifications': case 'public': case 'thread': return columnType; default: - if (columnType.includes('list:')) { + if (columnType.startsWith('account:')) { + return 'account'; + } + if (columnType.startsWith('list:')) { return 'home'; } return 'public'; // community, account, hashtag } }; -const filterSelector = (contextType?: string) => (filters: Array) => +const filterSelector = (contextType?: FilterContextType) => (filters: Array) => filters.filter( (filter) => - (!contextType || filter.context.includes(toServerSideType(contextType))) && + (!contextType || filter.context.includes(timelineToFilterContextType(contextType))) && (filter.expires_at === null || Date.parse(filter.expires_at) > Date.now()), ); -const useFiltersByContext = (contextType: string) => useFilters(filterSelector(contextType)); +const useFiltersByContext = (contextType: FilterContextType) => + useFilters(filterSelector(contextType)); const useFilter = (filterId?: string) => { const client = useClient(); @@ -123,4 +131,6 @@ export { useCreateFilter, useUpdateFilter, useDeleteFilter, + timelineToFilterContextType, + type FilterContextType, }; diff --git a/packages/nicolium/src/queries/statuses/use-status.ts b/packages/nicolium/src/queries/statuses/use-status.ts index a476676c2..a550fe448 100644 --- a/packages/nicolium/src/queries/statuses/use-status.ts +++ b/packages/nicolium/src/queries/statuses/use-status.ts @@ -3,15 +3,18 @@ import { useMemo } from 'react'; import { importEntities } from '@/actions/importer'; import { useClient } from '@/hooks/use-client'; +import { useFeatures } from '@/hooks/use-features'; import { normalizeStatus, type NormalizedStatus } from '@/normalizers/status'; +import { useFilters } from '@/queries/settings/use-filters'; import { useContextsActions } from '@/stores/contexts'; +import { checkFiltered } from '@/utils/filters'; import { useAccount } from '../accounts/use-account'; import { useAccounts } from '../accounts/use-accounts'; import { queryClient } from '../client'; import { queryKeys } from '../keys'; -import type { Context, AsyncRefreshHeader, Account } from 'pl-api'; +import type { Context, AsyncRefreshHeader, Account, Filter, FilterResult } from 'pl-api'; const minifyContext = ({ ancestors, @@ -71,10 +74,23 @@ const useStatusQuery = (statusId?: string) => { }, [statusQuery.data, account.data, accounts]) as unknown as UseQueryResult; }; +const emptyFilters: Array = []; +const emptyFilterResults: Array = []; +const selectAllFilters = (data: Array) => data; +const selectNoFilters = () => emptyFilters; + const useStatus = ( statusId?: string, - { withContext }: { withContext?: boolean; contextType?: string } = {}, + { + withContext, + withFilteredResults, + }: { withContext?: boolean; withFilteredResults?: boolean } = {}, ) => { + const features = useFeatures(); + const withClientSideFilters = !!(features.filters && !features.filtersV2 && withFilteredResults); + + const { data: filters } = useFilters(withClientSideFilters ? selectAllFilters : selectNoFilters); + const { refetch: refetchContext } = useStatusContext(withContext ? statusId : undefined); const statusQuery = useStatusQuery(statusId); @@ -84,8 +100,16 @@ const useStatus = ( const account = useAccount(statusQuery.data?.account_id ?? undefined); + const clientFilterResults = useMemo(() => { + if (!withClientSideFilters || !filters?.length || !statusQuery.data) return emptyFilterResults; + return checkFiltered(statusQuery.data.search_index, filters); + }, [withClientSideFilters, filters, statusQuery.data?.search_index]); + return useMemo(() => { if (!statusQuery.data) return { ...statusQuery, refetchContext }; + + const filtered = withClientSideFilters ? clientFilterResults : statusQuery.data.filtered; + return { ...statusQuery, data: { @@ -93,6 +117,7 @@ const useStatus = ( account: account.data!, reblog: reblogQuery.data ?? null, quote: quoteQuery.data ?? null, + filtered, }, refetchContext, }; @@ -101,6 +126,7 @@ const useStatus = ( reblogQuery.data, quoteQuery.data, account.data, + clientFilterResults, ]) as unknown as UseQueryResult & { refetchContext: () => void }; }; diff --git a/packages/nicolium/src/selectors/index.ts b/packages/nicolium/src/selectors/index.ts index 7e29ba2f5..f78d6ae27 100644 --- a/packages/nicolium/src/selectors/index.ts +++ b/packages/nicolium/src/selectors/index.ts @@ -6,69 +6,11 @@ import { queryKeys } from '@/queries/keys'; import { useSettingsStore } from '@/stores/settings'; import { getDomain } from '@/utils/accounts'; import ConfigDB from '@/utils/config-db'; +import { regexFromFilters } from '@/utils/filters'; import { shouldFilter } from '@/utils/timelines'; import type { MRFSimple } from '@/schemas/pleroma'; import type { RootState } from '@/store'; -import type { Filter } from 'pl-api'; - -const escapeRegExp = (string: string) => string.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string - -const regexFromFilters = (filters: Array) => { - if (filters.length === 0) return null; - - return new RegExp( - filters - .map((filter) => - filter.keywords - .map((keyword) => { - let expr = escapeRegExp(keyword.keyword); - - if (keyword.whole_word) { - if (/^[\w]/.test(expr)) { - expr = `\\b${expr}`; - } - - if (/[\w]$/.test(expr)) { - expr = `${expr}\\b`; - } - } - - return expr; - }) - .join('|'), - ) - .join('|'), - 'i', - ); -}; - -// const checkFiltered = (index: string, filters: Array) => -// filters.reduce( -// (result: Array, filter) => -// result.concat( -// filter.keywords.reduce((result: Array, keyword) => { -// let expr = escapeRegExp(keyword.keyword); - -// if (keyword.whole_word) { -// if (/^[\w]/.test(expr)) { -// expr = `\\b${expr}`; -// } - -// if (/[\w]$/.test(expr)) { -// expr = `${expr}\\b`; -// } -// } - -// const regex = new RegExp(expr); - -// if (regex.test(index)) -// return result.concat({ filter, keyword_matches: null, status_matches: null }); -// return result; -// }, []), -// ), -// [], -// ); const getSimplePolicy = createSelector( [ diff --git a/packages/nicolium/src/utils/filters.ts b/packages/nicolium/src/utils/filters.ts new file mode 100644 index 000000000..4d6013097 --- /dev/null +++ b/packages/nicolium/src/utils/filters.ts @@ -0,0 +1,58 @@ +import type { Filter, FilterResult } from 'pl-api'; + +const escapeRegExp = (string: string) => string.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const regexFromFilters = (filters: Array): RegExp | null => { + if (filters.length === 0) return null; + + return new RegExp( + filters + .map((filter) => + filter.keywords + .map((keyword) => { + let expr = escapeRegExp(keyword.keyword); + + if (keyword.whole_word) { + if (/^[\w]/.test(expr)) { + expr = `\\b${expr}`; + } + + if (/[\w]$/.test(expr)) { + expr = `${expr}\\b`; + } + } + + return expr; + }) + .join('|'), + ) + .join('|'), + 'i', + ); +}; + +const checkFiltered = (index: string, filters: Array): Array => + filters.reduce>((results, filter) => { + const { keywords, statuses, ...filterWithoutKeywords } = filter; + + for (const keyword of keywords) { + let expr = escapeRegExp(keyword.keyword); + + if (keyword.whole_word) { + if (/^[\w]/.test(expr)) expr = `\\b${expr}`; + if (/[\w]$/.test(expr)) expr = `${expr}\\b`; + } + + if (new RegExp(expr, 'i').test(index)) { + results.push({ + filter: filterWithoutKeywords, + keyword_matches: keyword.keyword, + status_matches: null, + }); + } + } + + return results; + }, []); + +export { escapeRegExp, regexFromFilters, checkFiltered };