nicolium: reintroduce status filtering

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-03-01 20:44:27 +01:00
parent 9fc655db87
commit 335910611c
10 changed files with 126 additions and 76 deletions

View File

@ -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<IStatusList> = ({
}) => {
const node = useRef<VirtuosoHandle | null>(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<IStatusList> = ({
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<IStatusList> = ({
featured
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType={timelineId}
contextType={contextType}
showGroup={showGroup}
variant='slim'
/>

View File

@ -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<IStatus> = (props) => {
fromBookmarks = false,
fromHomeTimeline = false,
className,
contextType,
} = props;
const intl = useIntl();
@ -202,7 +206,8 @@ const Status: React.FC<IStatus> = (props) => {
const didShowCard = useRef(false);
const node = useRef<HTMLDivElement>(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<IStatus> = (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<IStatus> = (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.

View File

@ -5,7 +5,6 @@ import { useStatus } from '@/queries/statuses/use-status';
interface IStatusContainer extends Omit<IStatus, 'status'> {
id: string;
contextType?: string;
/** @deprecated Unused. */
otherAccounts?: any;
}
@ -15,9 +14,9 @@ interface IStatusContainer extends Omit<IStatus, 'status'> {
* @deprecated Use the Status component directly.
*/
const StatusContainer: React.FC<IStatusContainer> = (props) => {
const { id, contextType: _contextType } = props;
const { id } = props;
const { data: status } = useStatus(id);
const { data: status } = useStatus(id, { withFilteredResults: true });
if (status) {
return <Status {...props} status={status} />;

View File

@ -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;

View File

@ -9,7 +9,7 @@ interface IQuotedStatusContainer {
}
const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ statusId }) => {
const { data: status } = useStatus(statusId);
const { data: status } = useStatus(statusId, { withFilteredResults: true });
if (!status) {
return null;

View File

@ -85,6 +85,7 @@ const AccountTimelinePage: React.FC = () => {
return (
<StatusList
timelineId={`account:${path}`}
scrollKey='account_timeline'
statusIds={statusIds}
featuredStatusIds={showPins ? featuredStatusIds : undefined}

View File

@ -9,6 +9,8 @@ import { queryKeys } from '../keys';
import type { CreateFilterParams, Filter, UpdateFilterParams } from 'pl-api';
type FilterContextType = Filter['context'][0];
function useFilters<T>(select: (data: Array<Filter>) => T): UseQueryResult<T, Error>;
function useFilters(): UseQueryResult<Array<Filter>, Error>;
function useFilters<T = Array<Filter>>(select?: (data: Array<Filter>) => T) {
@ -34,29 +36,35 @@ function useFilters<T = Array<Filter>>(select?: (data: Array<Filter>) => 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<Filter>) =>
const filterSelector = (contextType?: FilterContextType) => (filters: Array<Filter>) =>
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,
};

View File

@ -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<NormalizedStatus>;
};
const emptyFilters: Array<Filter> = [];
const emptyFilterResults: Array<FilterResult> = [];
const selectAllFilters = (data: Array<Filter>) => 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<SelectedStatus> & { refetchContext: () => void };
};

View File

@ -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<Filter>) => {
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<Filter>) =>
// filters.reduce(
// (result: Array<FilterResult>, filter) =>
// result.concat(
// filter.keywords.reduce((result: Array<FilterResult>, 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(
[

View File

@ -0,0 +1,58 @@
import type { Filter, FilterResult } from 'pl-api';
const escapeRegExp = (string: string) => string.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexFromFilters = (filters: Array<Filter>): 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<Filter>): Array<FilterResult> =>
filters.reduce<Array<FilterResult>>((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 };