filters v2

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-03-03 22:40:39 +01:00
parent aad7df89a5
commit 4200fa2df4
18 changed files with 267 additions and 68 deletions

View file

@ -72,10 +72,10 @@ One disadvantage of this approach is that it does not help the software spread.
# License & Credits # License & Credits
© Alex Gleason & other Soapbox contributors © Alex Gleason & other Soapbox contributors
© Eugen Rochko & other Mastodon contributors © Eugen Rochko & other Mastodon contributors
© Trump Media & Technology Group © Trump Media & Technology Group
© Gab AI, Inc. © Gab AI, Inc.
Soapbox is free software: you can redistribute it and/or modify Soapbox is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by

View file

@ -2,4 +2,4 @@
- verified.svg - Created by Alex Gleason. CC0 - verified.svg - Created by Alex Gleason. CC0
Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg

View file

@ -8,9 +8,13 @@ import api from '../api';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; const FILTERS_V1_FETCH_REQUEST = 'FILTERS_V1_FETCH_REQUEST';
const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; const FILTERS_V1_FETCH_SUCCESS = 'FILTERS_V1_FETCH_SUCCESS';
const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; const FILTERS_V1_FETCH_FAIL = 'FILTERS_V1_FETCH_FAIL';
const FILTERS_V2_FETCH_REQUEST = 'FILTERS_V2_FETCH_REQUEST';
const FILTERS_V2_FETCH_SUCCESS = 'FILTERS_V2_FETCH_SUCCESS';
const FILTERS_V2_FETCH_FAIL = 'FILTERS_V2_FETCH_FAIL';
const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
@ -25,6 +29,50 @@ const messages = defineMessages({
removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' },
}); });
const fetchFiltersV1 = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTERS_V1_FETCH_REQUEST,
skipLoading: true,
});
api(getState)
.get('/api/v1/filters')
.then(({ data }) => dispatch({
type: FILTERS_V1_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTERS_V1_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFiltersV2 = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: FILTERS_V2_FETCH_REQUEST,
skipLoading: true,
});
api(getState)
.get('/api/v2/filters')
.then(({ data }) => dispatch({
type: FILTERS_V2_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTERS_V2_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
const fetchFilters = () => const fetchFilters = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
@ -33,26 +81,9 @@ const fetchFilters = () =>
const instance = state.instance; const instance = state.instance;
const features = getFeatures(instance); const features = getFeatures(instance);
if (!features.filters) return; if (features.filtersV2) return dispatch(fetchFiltersV2());
dispatch({ if (features.filters) return dispatch(fetchFiltersV1());
type: FILTERS_FETCH_REQUEST,
skipLoading: true,
});
api(getState)
.get('/api/v1/filters')
.then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTERS_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
}; };
const createFilter = (phrase: string, expires_at: string, context: Array<string>, whole_word: boolean, irreversible: boolean) => const createFilter = (phrase: string, expires_at: string, context: Array<string>, whole_word: boolean, irreversible: boolean) =>
@ -84,9 +115,12 @@ const deleteFilter = (id: string) =>
}; };
export { export {
FILTERS_FETCH_REQUEST, FILTERS_V1_FETCH_REQUEST,
FILTERS_FETCH_SUCCESS, FILTERS_V1_FETCH_SUCCESS,
FILTERS_FETCH_FAIL, FILTERS_V1_FETCH_FAIL,
FILTERS_V2_FETCH_REQUEST,
FILTERS_V2_FETCH_SUCCESS,
FILTERS_V2_FETCH_FAIL,
FILTERS_CREATE_REQUEST, FILTERS_CREATE_REQUEST,
FILTERS_CREATE_SUCCESS, FILTERS_CREATE_SUCCESS,
FILTERS_CREATE_FAIL, FILTERS_CREATE_FAIL,
@ -96,4 +130,4 @@ export {
fetchFilters, fetchFilters,
createFilter, createFilter,
deleteFilter, deleteFilter,
}; };

View file

@ -48,6 +48,8 @@ const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const STATUS_UNFILTER = 'STATUS_UNFILTER';
const statusExists = (getState: () => RootState, statusId: string) => { const statusExists = (getState: () => RootState, statusId: string) => {
return (getState().statuses.get(statusId) || null) !== null; return (getState().statuses.get(statusId) || null) !== null;
}; };
@ -335,6 +337,11 @@ const undoStatusTranslation = (id: string) => ({
id, id,
}); });
const unfilterStatus = (id: string) => ({
type: STATUS_UNFILTER,
id,
});
export { export {
STATUS_CREATE_REQUEST, STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS, STATUS_CREATE_SUCCESS,
@ -363,6 +370,7 @@ export {
STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL, STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
createStatus, createStatus,
editStatus, editStatus,
fetchStatus, fetchStatus,
@ -381,4 +389,5 @@ export {
toggleStatusHidden, toggleStatusHidden,
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
unfilterStatus,
}; };

View file

@ -7,7 +7,7 @@ import { useHistory } from 'react-router-dom';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose'; import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden } from 'soapbox/actions/statuses'; import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import TranslateButton from 'soapbox/components/translate-button'; import TranslateButton from 'soapbox/components/translate-button';
import AccountContainer from 'soapbox/containers/account-container'; import AccountContainer from 'soapbox/containers/account-container';
@ -93,6 +93,8 @@ const Status: React.FC<IStatus> = (props) => {
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`; const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
const group = actualStatus.group as GroupEntity | null; const group = actualStatus.group as GroupEntity | null;
const filtered = (status.filtered.size || actualStatus.filtered.size) > 0;
// Track height changes we know about to compensate scrolling. // Track height changes we know about to compensate scrolling.
useEffect(() => { useEffect(() => {
didShowCard.current = Boolean(!muted && !hidden && status?.card); didShowCard.current = Boolean(!muted && !hidden && status?.card);
@ -202,6 +204,8 @@ const Status: React.FC<IStatus> = (props) => {
_expandEmojiSelector(); _expandEmojiSelector();
}; };
const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.size ? status.id : actualStatus.id));
const _expandEmojiSelector = (): void => { const _expandEmojiSelector = (): void => {
const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji?.focus(); firstEmoji?.focus();
@ -281,7 +285,7 @@ const Status: React.FC<IStatus> = (props) => {
); );
} }
if (status.filtered || actualStatus.filtered) { if (filtered && status.showFiltered) {
const minHandlers = muted ? undefined : { const minHandlers = muted ? undefined : {
moveUp: handleHotkeyMoveUp, moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown, moveDown: handleHotkeyMoveDown,
@ -291,7 +295,11 @@ const Status: React.FC<IStatus> = (props) => {
<HotKeys handlers={minHandlers}> <HotKeys handlers={minHandlers}>
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}> <div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<Text theme='muted'> <Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' /> <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {status.filtered.join(', ')}.
{' '}
<button className='text-primary-600 hover:underline dark:text-accent-blue' onClick={handleUnfilter}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
</button>
</Text> </Text>
</div> </div>
</HotKeys> </HotKeys>

View file

@ -1131,7 +1131,7 @@
"status.embed": "Osadź", "status.embed": "Osadź",
"status.external": "View post on {domain}", "status.external": "View post on {domain}",
"status.favourite": "Zareaguj", "status.favourite": "Zareaguj",
"status.filtered": "Filtrowany(-a)", "status.filtered": "Filtrowany",
"status.interactions.favourites": "{count, plural, one {Polubienie} few {Polubienia} other {Polubień}}", "status.interactions.favourites": "{count, plural, one {Polubienie} few {Polubienia} other {Polubień}}",
"status.interactions.reblogs": "{count, plural, one {Podanie dalej} few {Podania dalej} other {Podań dalej}}", "status.interactions.reblogs": "{count, plural, one {Podanie dalej} few {Podania dalej} other {Podań dalej}}",
"status.load_more": "Załaduj więcej", "status.load_more": "Załaduj więcej",

View file

@ -0,0 +1,18 @@
/**
* Filter normalizer:
* Converts API filters into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/FilterKeyword/}
*/
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
// https://docs.joinmastodon.org/entities/FilterKeyword/
export const FilterKeywordRecord = ImmutableRecord({
id: '',
keyword: '',
whole_word: false,
});
export const normalizeFilterKeyword = (filterKeyword: Record<string, any>) =>
FilterKeywordRecord(
ImmutableMap(fromJS(filterKeyword)),
);

View file

@ -0,0 +1,22 @@
/**
* Filter normalizer:
* Converts API filters into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/FilterResult/}
*/
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
import { normalizeFilter } from './filter';
import type { Filter } from 'soapbox/types/entities';
// https://docs.joinmastodon.org/entities/FilterResult/
export const FilterResultRecord = ImmutableRecord({
filter: null as Filter | null,
keyword_matches: ImmutableList<string>(),
status_matches: ImmutableList<string>(),
});
export const normalizeFilterResult = (filterResult: Record<string, any>) =>
FilterResultRecord(
ImmutableMap(fromJS(filterResult)).update('filter', (filter: any) => normalizeFilter(filter) as any),
);

View file

@ -0,0 +1,17 @@
/**
* Filter normalizer:
* Converts API filters into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/FilterStatus/}
*/
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
// https://docs.joinmastodon.org/entities/FilterStatus/
export const FilterStatusRecord = ImmutableRecord({
id: '',
status_id: '',
});
export const normalizeFilterStatus = (filterStatus: Record<string, any>) =>
FilterStatusRecord(
ImmutableMap(fromJS(filterStatus)),
);

View file

@ -0,0 +1,24 @@
/**
* Filter normalizer:
* Converts API filters into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/V1_Filter/}
*/
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
import type { ContextType } from './filter';
// https://docs.joinmastodon.org/entities/V1_Filter/
export const FilterV1Record = ImmutableRecord({
id: '',
phrase: '',
context: ImmutableList<ContextType>(),
whole_word: false,
expires_at: '',
irreversible: false,
});
export const normalizeFilterV1 = (filter: Record<string, any>) => {
return FilterV1Record(
ImmutableMap(fromJS(filter)),
);
};

View file

@ -5,20 +5,39 @@
*/ */
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
import { FilterKeyword, FilterStatus } from 'soapbox/types/entities';
import { normalizeFilterKeyword } from './filter-keyword';
import { normalizeFilterStatus } from './filter-status';
export type ContextType = 'home' | 'public' | 'notifications' | 'thread'; export type ContextType = 'home' | 'public' | 'notifications' | 'thread';
export type FilterActionType = 'warn' | 'hide';
// https://docs.joinmastodon.org/entities/filter/ // https://docs.joinmastodon.org/entities/filter/
export const FilterRecord = ImmutableRecord({ export const FilterRecord = ImmutableRecord({
id: '', id: '',
phrase: '', title: '',
context: ImmutableList<ContextType>(), context: ImmutableList<ContextType>(),
whole_word: false,
expires_at: '', expires_at: '',
irreversible: false, filter_action: 'warn' as FilterActionType,
keywords: ImmutableList<FilterKeyword>(),
statuses: ImmutableList<FilterStatus>(),
}); });
export const normalizeFilter = (filter: Record<string, any>) => { const normalizeKeywords = (filter: ImmutableMap<string, any>) =>
return FilterRecord( filter.update('keywords', ImmutableList(), keywords =>
ImmutableMap(fromJS(filter)), keywords.map(normalizeFilterKeyword),
);
const normalizeStatuses = (filter: ImmutableMap<string, any>) =>
filter.update('statuses', ImmutableList(), statuses =>
statuses.map(normalizeFilterStatus),
);
export const normalizeFilter = (filter: Record<string, any>) =>
FilterRecord(
ImmutableMap(fromJS(filter)).withMutations(filter => {
normalizeKeywords(filter);
normalizeStatuses(filter);
}),
); );
};

View file

@ -10,6 +10,9 @@ export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
export { EmojiRecord, normalizeEmoji } from './emoji'; export { EmojiRecord, normalizeEmoji } from './emoji';
export { EmojiReactionRecord } from './emoji-reaction'; export { EmojiReactionRecord } from './emoji-reaction';
export { FilterRecord, normalizeFilter } from './filter'; export { FilterRecord, normalizeFilter } from './filter';
export { FilterKeywordRecord, normalizeFilterKeyword } from './filter-keyword';
export { FilterStatusRecord, normalizeFilterStatus } from './filter-status';
export { FilterV1Record, normalizeFilterV1 } from './filter-v1';
export { GroupRecord, normalizeGroup } from './group'; export { GroupRecord, normalizeGroup } from './group';
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship'; export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
export { HistoryRecord, normalizeHistory } from './history'; export { HistoryRecord, normalizeHistory } from './history';

View file

@ -50,6 +50,7 @@ export const StatusRecord = ImmutableRecord({
emojis: ImmutableList<Emoji>(), emojis: ImmutableList<Emoji>(),
favourited: false, favourited: false,
favourites_count: 0, favourites_count: 0,
filtered: ImmutableList<string>(),
group: null as EmbeddedEntity<Group>, group: null as EmbeddedEntity<Group>,
in_reply_to_account_id: null as string | null, in_reply_to_account_id: null as string | null,
in_reply_to_id: null as string | null, in_reply_to_id: null as string | null,
@ -78,9 +79,9 @@ export const StatusRecord = ImmutableRecord({
// Internal fields // Internal fields
contentHtml: '', contentHtml: '',
expectsCard: false, expectsCard: false,
filtered: false,
hidden: false, hidden: false,
search_index: '', search_index: '',
showFiltered: true,
spoilerHtml: '', spoilerHtml: '',
translation: null as ImmutableMap<string, string> | null, translation: null as ImmutableMap<string, string> | null,
}); });
@ -166,11 +167,6 @@ const fixQuote = (status: ImmutableMap<string, any>) => {
}); });
}; };
// Workaround for not yet implemented filtering from Mastodon 3.6
const fixFiltered = (status: ImmutableMap<string, any>) => {
status.delete('filtered');
};
/** If the status contains spoiler text, treat it as sensitive. */ /** If the status contains spoiler text, treat it as sensitive. */
const fixSensitivity = (status: ImmutableMap<string, any>) => { const fixSensitivity = (status: ImmutableMap<string, any>) => {
if (status.get('spoiler_text')) { if (status.get('spoiler_text')) {
@ -214,6 +210,13 @@ const fixContent = (status: ImmutableMap<string, any>) => {
} }
}; };
const normalizeFilterResults = (status: ImmutableMap<string, any>) =>
status.update('filtered', ImmutableList(), filterResults =>
filterResults.map((filterResult: ImmutableMap<string, any>) =>
filterResult.getIn(['filter', 'title']),
),
);
export const normalizeStatus = (status: Record<string, any>) => { export const normalizeStatus = (status: Record<string, any>) => {
return StatusRecord( return StatusRecord(
ImmutableMap(fromJS(status)).withMutations(status => { ImmutableMap(fromJS(status)).withMutations(status => {
@ -225,10 +228,10 @@ export const normalizeStatus = (status: Record<string, any>) => {
fixMentionsOrder(status); fixMentionsOrder(status);
addSelfMention(status); addSelfMention(status);
fixQuote(status); fixQuote(status);
fixFiltered(status);
fixSensitivity(status); fixSensitivity(status);
normalizeEvent(status); normalizeEvent(status);
fixContent(status); fixContent(status);
normalizeFilterResults(status);
}), }),
); );
}; };

View file

@ -1,22 +1,22 @@
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { normalizeFilter } from 'soapbox/normalizers'; import { normalizeFilterV1 } from 'soapbox/normalizers';
import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; import { FILTERS_V1_FETCH_SUCCESS } from '../actions/filters';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { APIEntity, Filter as FilterEntity } from 'soapbox/types/entities'; import type { APIEntity, FilterV1 as FilterV1Entity } from 'soapbox/types/entities';
type State = ImmutableList<FilterEntity>; type State = ImmutableList<FilterV1Entity>;
const importFilters = (_state: State, filters: APIEntity[]): State => { const importFiltersV1 = (_state: State, filters: APIEntity[]): State => {
return ImmutableList(filters.map((filter) => normalizeFilter(filter))); return ImmutableList(filters.map((filter) => normalizeFilterV1(filter)));
}; };
export default function filters(state: State = ImmutableList<FilterEntity>(), action: AnyAction): State { export default function filters(state: State = ImmutableList(), action: AnyAction): State {
switch (action.type) { switch (action.type) {
case FILTERS_FETCH_SUCCESS: case FILTERS_V1_FETCH_SUCCESS:
return importFilters(state, action.filters); return importFiltersV1(state, action.filters);
default: default:
return state; return state;
} }

View file

@ -38,6 +38,7 @@ import {
STATUS_DELETE_FAIL, STATUS_DELETE_FAIL,
STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
} from '../actions/statuses'; } from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
@ -287,6 +288,8 @@ export default function statuses(state = initialState, action: AnyAction): State
return importTranslation(state, action.id, action.translation); return importTranslation(state, action.id, action.translation);
case STATUS_TRANSLATE_UNDO: case STATUS_TRANSLATE_UNDO:
return deleteTranslation(state, action.id); return deleteTranslation(state, action.id);
case STATUS_UNFILTER:
return state.setIn([action.id, 'showFiltered'], false);
case TIMELINE_DELETE: case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references); return deleteStatus(state, action.id, action.references);
case EVENT_JOIN_REQUEST: case EVENT_JOIN_REQUEST:

View file

@ -10,12 +10,13 @@ import { getSettings } from 'soapbox/actions/settings';
import { getDomain } from 'soapbox/utils/accounts'; import { getDomain } from 'soapbox/utils/accounts';
import { validId } from 'soapbox/utils/auth'; import { validId } from 'soapbox/utils/auth';
import ConfigDB from 'soapbox/utils/config-db'; import ConfigDB from 'soapbox/utils/config-db';
import { getFeatures } from 'soapbox/utils/features';
import { shouldFilter } from 'soapbox/utils/timelines'; import { shouldFilter } from 'soapbox/utils/timelines';
import type { ContextType } from 'soapbox/normalizers/filter'; import type { ContextType } from 'soapbox/normalizers/filter';
import type { ReducerChat } from 'soapbox/reducers/chats'; import type { ReducerChat } from 'soapbox/reducers/chats';
import type { RootState } from 'soapbox/store'; import type { RootState } from 'soapbox/store';
import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities'; import type { FilterV1 as FilterV1Entity, Notification } from 'soapbox/types/entities';
const normalizeId = (id: any): string => typeof id === 'string' ? id : ''; const normalizeId = (id: any): string => typeof id === 'string' ? id : '';
@ -114,13 +115,13 @@ export const getFilters = (state: RootState, query: FilterContext) => {
const escapeRegExp = (string: string) => const escapeRegExp = (string: string) =>
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
export const regexFromFilters = (filters: ImmutableList<FilterEntity>) => { export const regexFromFilters = (filters: ImmutableList<FilterV1Entity>) => {
if (filters.size === 0) return null; if (filters.size === 0) return null;
return new RegExp(filters.map(filter => { return new RegExp(filters.map(filter => {
let expr = escapeRegExp(filter.get('phrase')); let expr = escapeRegExp(filter.phrase);
if (filter.get('whole_word')) { if (filter.whole_word) {
if (/^[\w]/.test(expr)) { if (/^[\w]/.test(expr)) {
expr = `\\b${expr}`; expr = `\\b${expr}`;
} }
@ -134,6 +135,26 @@ export const regexFromFilters = (filters: ImmutableList<FilterEntity>) => {
}).join('|'), 'i'); }).join('|'), 'i');
}; };
const checkFiltered = (index: string, filters: ImmutableList<FilterV1Entity>) =>
filters.reduce((result, filter) => {
let expr = escapeRegExp(filter.phrase);
if (filter.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.push(filter.phrase);
return result;
}, ImmutableList<string>());
type APIStatus = { id: string, username?: string }; type APIStatus = { id: string, username?: string };
export const makeGetStatus = () => { export const makeGetStatus = () => {
@ -147,9 +168,10 @@ export const makeGetStatus = () => {
(_state: RootState, { username }: APIStatus) => username, (_state: RootState, { username }: APIStatus) => username,
getFilters, getFilters,
(state: RootState) => state.me, (state: RootState) => state.me,
(state: RootState) => getFeatures(state.instance),
], ],
(statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me) => { (statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me, features) => {
if (!statusBase || !accountBase) return null; if (!statusBase || !accountBase) return null;
const accountUsername = accountBase.acct; const accountUsername = accountBase.acct;
@ -165,16 +187,18 @@ export const makeGetStatus = () => {
statusReblog = undefined; statusReblog = undefined;
} }
const regex = (accountReblog || accountBase).id !== me && regexFromFilters(filters);
const filtered = regex && regex.test(statusReblog?.search_index || statusBase.search_index);
return statusBase.withMutations(map => { return statusBase.withMutations(map => {
map.set('reblog', statusReblog || null); map.set('reblog', statusReblog || null);
// @ts-ignore :( // @ts-ignore :(
map.set('account', accountBase || null); map.set('account', accountBase || null);
// @ts-ignore // @ts-ignore
map.set('group', group || null); map.set('group', group || null);
map.set('filtered', Boolean(filtered));
if (features.filters && (accountReblog || accountBase).id !== me) {
const filtered = checkFiltered(statusReblog?.search_index || statusBase.search_index, filters);
map.set('filtered', filtered);
}
}); });
}, },
); );

View file

@ -12,6 +12,9 @@ import {
EmojiReactionRecord, EmojiReactionRecord,
FieldRecord, FieldRecord,
FilterRecord, FilterRecord,
FilterKeywordRecord,
FilterStatusRecord,
FilterV1Record,
GroupRecord, GroupRecord,
GroupRelationshipRecord, GroupRelationshipRecord,
HistoryRecord, HistoryRecord,
@ -44,6 +47,9 @@ type Emoji = ReturnType<typeof EmojiRecord>;
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>; type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
type Field = ReturnType<typeof FieldRecord>; type Field = ReturnType<typeof FieldRecord>;
type Filter = ReturnType<typeof FilterRecord>; type Filter = ReturnType<typeof FilterRecord>;
type FilterKeyword = ReturnType<typeof FilterKeywordRecord>;
type FilterStatus = ReturnType<typeof FilterStatusRecord>;
type FilterV1 = ReturnType<typeof FilterV1Record>;
type Group = ReturnType<typeof GroupRecord>; type Group = ReturnType<typeof GroupRecord>;
type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>; type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>;
type History = ReturnType<typeof HistoryRecord>; type History = ReturnType<typeof HistoryRecord>;
@ -89,6 +95,9 @@ export {
EmojiReaction, EmojiReaction,
Field, Field,
Filter, Filter,
FilterKeyword,
FilterStatus,
FilterV1,
Group, Group,
GroupRelationship, GroupRelationship,
History, History,

View file

@ -443,13 +443,19 @@ const getInstanceFeatures = (instance: Instance) => {
/** /**
* Can edit and manage timeline filters (aka "muted words"). * Can edit and manage timeline filters (aka "muted words").
* @see {@link https://docs.joinmastodon.org/methods/accounts/filters/} * @see {@link https://docs.joinmastodon.org/methods/filters/#v1}
*/ */
filters: any([ filters: any([
v.software === MASTODON && lt(v.compatVersion, '3.6.0'), v.software === MASTODON && lt(v.compatVersion, '3.6.0'),
v.software === PLEROMA, v.software === PLEROMA,
]), ]),
/**
* Can edit and manage timeline filters (aka "muted words").
* @see {@link https://docs.joinmastodon.org/methods/accounts/filters/}
*/
filtersV2: v.software === MASTODON && gte(v.compatVersion, '3.6.0'),
/** /**
* Allows setting the focal point of a media attachment. * Allows setting the focal point of a media attachment.
* @see {@link https://docs.joinmastodon.org/methods/statuses/media/} * @see {@link https://docs.joinmastodon.org/methods/statuses/media/}