nicolium: reintroduce status filtering
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -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'
|
||||
/>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -85,6 +85,7 @@ const AccountTimelinePage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<StatusList
|
||||
timelineId={`account:${path}`}
|
||||
scrollKey='account_timeline'
|
||||
statusIds={statusIds}
|
||||
featuredStatusIds={showPins ? featuredStatusIds : undefined}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@ -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(
|
||||
[
|
||||
|
||||
58
packages/nicolium/src/utils/filters.ts
Normal file
58
packages/nicolium/src/utils/filters.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user