+++ /dev/null
-import api from 'flavours/glitch/util/api';
-
-export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
-export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
-export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
-
-export const fetchFilters = () => (dispatch, getState) => {
- dispatch({
- 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,
- }));
-};
export const STATUS_IMPORT = 'STATUS_IMPORT';
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
export const POLLS_IMPORT = 'POLLS_IMPORT';
+export const FILTERS_IMPORT = 'FILTERS_IMPORT';
function pushUnique(array, object) {
if (array.every(element => element.id !== object.id)) {
return { type: STATUSES_IMPORT, statuses };
}
+export function importFilters(filters) {
+ return { type: FILTERS_IMPORT, filters };
+}
+
export function importPolls(polls) {
return { type: POLLS_IMPORT, polls };
}
const accounts = [];
const normalStatuses = [];
const polls = [];
+ const filters = [];
function processStatus(status) {
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings')));
pushUnique(accounts, status.account);
+ if (status.filtered) {
+ status.filtered.forEach(result => pushUnique(filters, result.filter));
+ }
+
if (status.reblog && status.reblog.id) {
processStatus(status.reblog);
}
dispatch(importPolls(polls));
dispatch(importFetchedAccounts(accounts));
dispatch(importStatuses(normalStatuses));
+ dispatch(importFilters(filters));
};
}
return account;
}
+export function normalizeFilterResult(result) {
+ const normalResult = { ...result };
+
+ normalResult.filter = normalResult.filter.id;
+
+ return normalResult;
+}
+
export function normalizeStatus(status, normalOldStatus, settings) {
const normalStatus = { ...status };
normalStatus.account = status.account.id;
normalStatus.poll = status.poll.id;
}
+ if (status.filtered) {
+ normalStatus.filtered = status.filtered.map(normalizeFilterResult);
+ }
+
// Only calculate these values when status first encountered and
// when the underlying values change. Otherwise keep the ones
// already in the reducer
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from 'flavours/glitch/util/html';
-import { getFiltersRegex } from 'flavours/glitch/selectors';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
import compareId from 'flavours/glitch/util/compare_id';
-import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
import { requestNotificationPermission } from 'flavours/glitch/util/notifications';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type;
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
- const filters = getFiltersRegex(getState(), { contextType: 'notifications' });
let filtered = false;
- if (['mention', 'status'].includes(notification.type)) {
- const dropRegex = filters[0];
- const regex = filters[1];
- const searchIndex = searchTextFromRawStatus(notification.status);
+ if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
+ const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
- if (dropRegex && dropRegex.test(searchIndex)) {
+ if (filters.some(result => result.filter.filter_action === 'hide')) {
return;
}
- filtered = regex && regex.test(searchIndex);
+ filtered = filters.length > 0;
}
if (['follow_request'].includes(notification.type)) {
updateReaction as updateAnnouncementsReaction,
deleteAnnouncement,
} from './announcements';
-import { fetchFilters } from './filters';
import { getLocale } from 'mastodon/locales';
const { messages } = getLocale();
case 'conversation':
dispatch(updateConversations(JSON.parse(data.payload)));
break;
- case 'filters_changed':
- dispatch(fetchFilters());
- break;
case 'announcement':
dispatch(updateAnnouncements(JSON.parse(data.payload)));
break;
}
handleUnfilterClick = e => {
- const { onUnfilter, status } = this.props;
- onUnfilter(status.get('reblog') ? status.get('reblog') : status, () => this.setState({ forceFilter: false }));
+ this.setState({ forceFilter: false });
+ e.preventDefault();
}
handleFilterClick = () => {
);
}
- const filtered = (status.get('filtered') || status.getIn(['reblog', 'filtered'])) && settings.get('filtering_behavior') !== 'content_warning';
- if (forceFilter === undefined ? filtered : forceFilter) {
+ const matchedFilters = status.get('filtered') || status.getIn(['reblog', 'filtered']);
+ if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
const minHandlers = this.props.muted ? {} : {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
return (
<HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
- <FormattedMessage id='status.filtered' defaultMessage='Filtered' />
- {settings.get('filtering_behavior') !== 'upstream' && ' '}
- {settings.get('filtering_behavior') !== 'upstream' && (
- <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
- <FormattedMessage id='status.show_filter_reason' defaultMessage='(show why)' />
- </button>
- )}
+ <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
+ {' '}
+ <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
+ <FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
+ </button>
</div>
</HotKeys>
);
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
<StatusActionBar
- {...other}
status={status}
account={status.get('account')}
showReplyCount={settings.get('show_reply_count')}
- onFilter={this.handleFilterClick}
+ onFilter={matchedFilters && this.handleFilterClick}
+ {...other}
/>
) : null}
{notification ? (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
- const filterButton = status.get('filtered') && (
- <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
- );
-
let replyButton = (
<IconButton
className='status__action-bar-button'
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
+ const filterButton = this.props.onFilter && (
+ <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
+ );
+
return (
<div className='status__action-bar'>
{replyButton}
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton}
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
+
{filterButton}
<div className='status__action-bar-dropdown'>
import { connect } from 'react-redux';
import Status from 'flavours/glitch/components/status';
import { List as ImmutableList } from 'immutable';
-import { makeGetStatus, regexFromFilters, toServerSideType } from 'flavours/glitch/selectors';
+import { makeGetStatus } from 'flavours/glitch/selectors';
import {
replyCompose,
mentionCompose,
dispatch(initBlockModal(account));
},
- onUnfilter (status, onConfirm) {
- dispatch((_, getState) => {
- let state = getState();
- const serverSideType = toServerSideType(contextType);
- const enabledFilters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray();
- const searchIndex = status.get('search_index');
- const matchingFilters = enabledFilters.filter(filter => regexFromFilters([filter]).test(searchIndex));
- dispatch(openModal('CONFIRM', {
- message: [
- <FormattedMessage id='confirmations.unfilter' defaultMessage='Information about this filtered toot' />,
- <div className='filtered-status-info'>
- <Spoilers spoilerText={intl.formatMessage(messages.author)}>
- <AccountContainer id={status.getIn(['account', 'id'])} />
- </Spoilers>
- <Spoilers spoilerText={intl.formatMessage(messages.matchingFilters, {count: matchingFilters.size})}>
- <ul>
- {matchingFilters.map(filter => (
- <li>
- {filter.get('phrase')}
- {!!filterEditLink && ' '}
- {!!filterEditLink && (
- <a
- target='_blank'
- className='filtered-status-edit-link'
- title={intl.formatMessage(messages.editFilter)}
- href={filterEditLink(filter.get('id'))}
- >
- <Icon id='pencil' />
- </a>
- )}
- </li>
- ))}
- </ul>
- </Spoilers>
- </div>
- ],
- confirm: intl.formatMessage(messages.unfilterConfirm),
- onConfirm: onConfirm,
- }));
- });
- },
-
onReport (status) {
dispatch(initReport(status.get('account'), status));
},
import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
-import { fetchFilters } from 'flavours/glitch/actions/filters';
import { fetchRules } from 'flavours/glitch/actions/rules';
import { clearHeight } from 'flavours/glitch/actions/height_cache';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());
- setTimeout(() => this.props.dispatch(fetchFilters()), 500);
+
setTimeout(() => this.props.dispatch(fetchRules()), 3000);
}
-import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
-import { List as ImmutableList, fromJS } from 'immutable';
+import { FILTERS_IMPORT } from '../actions/importer';
+import { Map as ImmutableMap, is, fromJS } from 'immutable';
-export default function filters(state = ImmutableList(), action) {
+const normalizeFilter = (state, filter) => {
+ const normalizedFilter = fromJS({
+ id: filter.id,
+ title: filter.title,
+ context: filter.context,
+ filter_action: filter.filter_action,
+ expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null,
+ });
+
+ if (is(state.get(filter.id), normalizedFilter)) {
+ return state;
+ } else {
+ return state.set(filter.id, normalizedFilter);
+ }
+};
+
+const normalizeFilters = (state, filters) => {
+ filters.forEach(filter => {
+ state = normalizeFilter(state, filter);
+ });
+
+ return state;
+};
+
+export default function filters(state = ImmutableMap(), action) {
switch(action.type) {
- case FILTERS_FETCH_SUCCESS:
- return fromJS(action.filters);
+ case FILTERS_IMPORT:
+ return normalizeFilters(state, action.filters);
default:
return state;
}
const escapeRegExp = string =>
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
-export const regexFromFilters = filters => {
- if (filters.size === 0) {
+const regexFromKeywords = keywords => {
+ if (keywords.size === 0) {
return null;
}
- return new RegExp(filters.map(filter => {
- let expr = escapeRegExp(filter.get('phrase'));
+ return new RegExp(keywords.map(keyword_filter => {
+ let expr = escapeRegExp(keyword_filter.get('keyword'));
- if (filter.get('whole_word')) {
+ if (keyword_filter.get('whole_word')) {
if (/^[\w]/.test(expr)) {
expr = `\\b${expr}`;
}
}).join('|'), 'i');
};
-// Memoize the filter regexps for each valid server contextType
-const makeGetFiltersRegex = () => {
- let memo = {};
+const getFilters = (state, { contextType }) => {
+ if (!contextType) return null;
- return (state, { contextType }) => {
- if (!contextType) return ImmutableList();
+ const serverSideType = toServerSideType(contextType);
+ const now = new Date();
- const serverSideType = toServerSideType(contextType);
- const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
-
- if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) {
- const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
- const regex = regexFromFilters(filters);
- memo[serverSideType] = { filters: filters, results: [dropRegex, regex] };
- }
- return memo[serverSideType].results;
- };
+ return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now));
};
-export const getFiltersRegex = makeGetFiltersRegex();
-
export const makeGetStatus = () => {
return createSelector(
[
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
- (state, _) => state.getIn(['local_settings', 'filtering_behavior']),
- (state, _) => state.get('filters', ImmutableList()),
- (_, { contextType }) => contextType,
- getFiltersRegex,
+ getFilters,
],
- (statusBase, statusReblog, accountBase, accountReblog, filteringBehavior, filters, contextType, filtersRegex) => {
+ (statusBase, statusReblog, accountBase, accountReblog, filters) => {
if (!statusBase) {
return null;
}
- const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
-
- if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
- return null;
- }
-
- const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1];
let filtered = false;
+ if ((accountReblog || accountBase).get('id') !== me && filters) {
+ let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList();
+ if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
+ return null;
+ }
+ if (!filterResults.isEmpty()) {
+ filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
+ }
+ }
if (statusReblog) {
- filtered = regex && regex.test(statusReblog.get('search_index'));
statusReblog = statusReblog.set('account', accountReblog);
statusReblog = statusReblog.set('filtered', filtered);
} else {
statusReblog = null;
}
- filtered = filtered || regex && regex.test(statusBase.get('search_index'));
-
- if (filtered && filteringBehavior === 'drop') {
- return null;
- } else if (filtered && filteringBehavior === 'content_warning') {
- let spoilerText = (statusReblog || statusBase).get('spoiler_text', '');
- const searchIndex = (statusReblog || statusBase).get('search_index');
- const serverSideType = toServerSideType(contextType);
- const enabledFilters = filters.filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray();
- const matchingFilters = enabledFilters.filter(filter => {
- const regexp = regexFromFilters([filter]);
- return regexp.test(searchIndex) && !regexp.test(spoilerText);
- });
- if (statusReblog) {
- statusReblog = statusReblog.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', '));
- statusReblog = statusReblog.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', '));
- } else {
- statusBase = statusBase.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', '));
- statusBase = statusBase.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', '));
- }
- }
-
return statusBase.withMutations(map => {
map.set('reblog', statusReblog);
map.set('account', accountBase);