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
© Alex Gleason & other Soapbox contributors
© Eugen Rochko & other Mastodon contributors
© Trump Media & Technology Group
© Gab AI, Inc.
© Alex Gleason & other Soapbox contributors
© Eugen Rochko & other Mastodon contributors
© Trump Media & Technology Group
© Gab AI, Inc.
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

@ -2,4 +2,4 @@
- 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';
const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
const FILTERS_V1_FETCH_REQUEST = 'FILTERS_V1_FETCH_REQUEST';
const FILTERS_V1_FETCH_SUCCESS = 'FILTERS_V1_FETCH_SUCCESS';
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_SUCCESS = 'FILTERS_CREATE_SUCCESS';
@ -25,36 +29,63 @@ const messages = defineMessages({
removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' },
});
const fetchFilters = () =>
const fetchFiltersV1 = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
dispatch({
type: FILTERS_V1_FETCH_REQUEST,
skipLoading: true,
});
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({
type: FILTERS_FETCH_REQUEST,
type: FILTERS_V2_FETCH_REQUEST,
skipLoading: true,
});
api(getState)
.get('/api/v1/filters')
.get('/api/v2/filters')
.then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS,
type: FILTERS_V2_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTERS_FETCH_FAIL,
type: FILTERS_V2_FETCH_FAIL,
err,
skipLoading: 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) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_CREATE_REQUEST });
@ -84,9 +115,12 @@ const deleteFilter = (id: string) =>
};
export {
FILTERS_FETCH_REQUEST,
FILTERS_FETCH_SUCCESS,
FILTERS_FETCH_FAIL,
FILTERS_V1_FETCH_REQUEST,
FILTERS_V1_FETCH_SUCCESS,
FILTERS_V1_FETCH_FAIL,
FILTERS_V2_FETCH_REQUEST,
FILTERS_V2_FETCH_SUCCESS,
FILTERS_V2_FETCH_FAIL,
FILTERS_CREATE_REQUEST,
FILTERS_CREATE_SUCCESS,
FILTERS_CREATE_FAIL,
@ -96,4 +130,4 @@ export {
fetchFilters,
createFilter,
deleteFilter,
};
};

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

@ -7,7 +7,7 @@ import { useHistory } from 'react-router-dom';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
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 TranslateButton from 'soapbox/components/translate-button';
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 group = actualStatus.group as GroupEntity | null;
const filtered = (status.filtered.size || actualStatus.filtered.size) > 0;
// Track height changes we know about to compensate scrolling.
useEffect(() => {
didShowCard.current = Boolean(!muted && !hidden && status?.card);
@ -202,6 +204,8 @@ const Status: React.FC<IStatus> = (props) => {
_expandEmojiSelector();
};
const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.size ? status.id : actualStatus.id));
const _expandEmojiSelector = (): void => {
const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
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 : {
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
@ -291,7 +295,11 @@ const Status: React.FC<IStatus> = (props) => {
<HotKeys handlers={minHandlers}>
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<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>
</div>
</HotKeys>

@ -1131,7 +1131,7 @@
"status.embed": "Osadź",
"status.external": "View post on {domain}",
"status.favourite": "Zareaguj",
"status.filtered": "Filtrowany(-a)",
"status.filtered": "Filtrowany",
"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.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 { 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 FilterActionType = 'warn' | 'hide';
// https://docs.joinmastodon.org/entities/filter/
export const FilterRecord = ImmutableRecord({
id: '',
phrase: '',
title: '',
context: ImmutableList<ContextType>(),
whole_word: false,
expires_at: '',
irreversible: false,
filter_action: 'warn' as FilterActionType,
keywords: ImmutableList<FilterKeyword>(),
statuses: ImmutableList<FilterStatus>(),
});
export const normalizeFilter = (filter: Record<string, any>) => {
return FilterRecord(
ImmutableMap(fromJS(filter)),
const normalizeKeywords = (filter: ImmutableMap<string, any>) =>
filter.update('keywords', ImmutableList(), keywords =>
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 { EmojiReactionRecord } from './emoji-reaction';
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 { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
export { HistoryRecord, normalizeHistory } from './history';

@ -50,6 +50,7 @@ export const StatusRecord = ImmutableRecord({
emojis: ImmutableList<Emoji>(),
favourited: false,
favourites_count: 0,
filtered: ImmutableList<string>(),
group: null as EmbeddedEntity<Group>,
in_reply_to_account_id: null as string | null,
in_reply_to_id: null as string | null,
@ -78,9 +79,9 @@ export const StatusRecord = ImmutableRecord({
// Internal fields
contentHtml: '',
expectsCard: false,
filtered: false,
hidden: false,
search_index: '',
showFiltered: true,
spoilerHtml: '',
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. */
const fixSensitivity = (status: ImmutableMap<string, any>) => {
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>) => {
return StatusRecord(
ImmutableMap(fromJS(status)).withMutations(status => {
@ -225,10 +228,10 @@ export const normalizeStatus = (status: Record<string, any>) => {
fixMentionsOrder(status);
addSelfMention(status);
fixQuote(status);
fixFiltered(status);
fixSensitivity(status);
normalizeEvent(status);
fixContent(status);
normalizeFilterResults(status);
}),
);
};

@ -1,22 +1,22 @@
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 { 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 => {
return ImmutableList(filters.map((filter) => normalizeFilter(filter)));
const importFiltersV1 = (_state: State, filters: APIEntity[]): State => {
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) {
case FILTERS_FETCH_SUCCESS:
return importFilters(state, action.filters);
case FILTERS_V1_FETCH_SUCCESS:
return importFiltersV1(state, action.filters);
default:
return state;
}

@ -38,6 +38,7 @@ import {
STATUS_DELETE_FAIL,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
} from '../actions/statuses';
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);
case STATUS_TRANSLATE_UNDO:
return deleteTranslation(state, action.id);
case STATUS_UNFILTER:
return state.setIn([action.id, 'showFiltered'], false);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case EVENT_JOIN_REQUEST:

@ -10,12 +10,13 @@ import { getSettings } from 'soapbox/actions/settings';
import { getDomain } from 'soapbox/utils/accounts';
import { validId } from 'soapbox/utils/auth';
import ConfigDB from 'soapbox/utils/config-db';
import { getFeatures } from 'soapbox/utils/features';
import { shouldFilter } from 'soapbox/utils/timelines';
import type { ContextType } from 'soapbox/normalizers/filter';
import type { ReducerChat } from 'soapbox/reducers/chats';
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 : '';
@ -114,13 +115,13 @@ export const getFilters = (state: RootState, query: FilterContext) => {
const escapeRegExp = (string: 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;
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)) {
expr = `\\b${expr}`;
}
@ -134,6 +135,26 @@ export const regexFromFilters = (filters: ImmutableList<FilterEntity>) => {
}).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 };
export const makeGetStatus = () => {
@ -147,9 +168,10 @@ export const makeGetStatus = () => {
(_state: RootState, { username }: APIStatus) => username,
getFilters,
(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;
const accountUsername = accountBase.acct;
@ -165,16 +187,18 @@ export const makeGetStatus = () => {
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 => {
map.set('reblog', statusReblog || null);
// @ts-ignore :(
map.set('account', accountBase || null);
// @ts-ignore
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,
FieldRecord,
FilterRecord,
FilterKeywordRecord,
FilterStatusRecord,
FilterV1Record,
GroupRecord,
GroupRelationshipRecord,
HistoryRecord,
@ -44,6 +47,9 @@ type Emoji = ReturnType<typeof EmojiRecord>;
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
type Field = ReturnType<typeof FieldRecord>;
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 GroupRelationship = ReturnType<typeof GroupRelationshipRecord>;
type History = ReturnType<typeof HistoryRecord>;
@ -89,6 +95,9 @@ export {
EmojiReaction,
Field,
Filter,
FilterKeyword,
FilterStatus,
FilterV1,
Group,
GroupRelationship,
History,

@ -443,13 +443,19 @@ const getInstanceFeatures = (instance: Instance) => {
/**
* 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([
v.software === MASTODON && lt(v.compatVersion, '3.6.0'),
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.
* @see {@link https://docs.joinmastodon.org/methods/statuses/media/}

Loading…
Cancel
Save