Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-filters-v2-hhz42m/deployments/2751
marcin mikołajczak 2 years ago
parent aad7df89a5
commit 4200fa2df4

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

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

@ -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,36 +29,63 @@ const messages = defineMessages({
removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' },
}); });
const fetchFilters = () => const fetchFiltersV1 = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; dispatch({
type: FILTERS_V1_FETCH_REQUEST,
const state = getState(); skipLoading: true,
const instance = state.instance; });
const features = getFeatures(instance);
if (!features.filters) return; 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({ dispatch({
type: FILTERS_FETCH_REQUEST, type: FILTERS_V2_FETCH_REQUEST,
skipLoading: true, skipLoading: true,
}); });
api(getState) api(getState)
.get('/api/v1/filters') .get('/api/v2/filters')
.then(({ data }) => dispatch({ .then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS, type: FILTERS_V2_FETCH_SUCCESS,
filters: data, filters: data,
skipLoading: true, skipLoading: true,
})) }))
.catch(err => dispatch({ .catch(err => dispatch({
type: FILTERS_FETCH_FAIL, type: FILTERS_V2_FETCH_FAIL,
err, err,
skipLoading: true, skipLoading: true,
skipAlert: true, skipAlert: true,
})); }));
}; };
const fetchFilters = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
if (features.filtersV2) return dispatch(fetchFiltersV2());
if (features.filters) return dispatch(fetchFiltersV1());
};
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) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_CREATE_REQUEST }); dispatch({ type: FILTERS_CREATE_REQUEST });
@ -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,
}; };

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

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

@ -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",

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

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

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

@ -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)),
);
};

@ -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);
}),
); );
};

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

@ -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);
}), }),
); );
}; };

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

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

@ -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);
}
}); });
}, },
); );

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

@ -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/}

Loading…
Cancel
Save