diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb
new file mode 100644
index 0000000000..c89722b853
--- /dev/null
+++ b/app/controllers/api/v1/filters_controller.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class Api::V1::FiltersController < Api::BaseController
+ before_action -> { doorkeeper_authorize! :read }, only: [:index, :show]
+ before_action -> { doorkeeper_authorize! :write }, except: [:index, :show]
+ before_action :require_user!
+ before_action :set_filters, only: :index
+ before_action :set_filter, only: [:show, :update, :destroy]
+
+ respond_to :json
+
+ def index
+ render json: @filters, each_serializer: REST::FilterSerializer
+ end
+
+ def create
+ @filter = current_account.custom_filters.create!(resource_params)
+ render json: @filter, serializer: REST::FilterSerializer
+ end
+
+ def show
+ render json: @filter, serializer: REST::FilterSerializer
+ end
+
+ def update
+ @filter.update!(resource_params)
+ render json: @filter, serializer: REST::FilterSerializer
+ end
+
+ def destroy
+ @filter.destroy!
+ render_empty
+ end
+
+ private
+
+ def set_filters
+ @filters = current_account.custom_filters
+ end
+
+ def set_filter
+ @filter = current_account.custom_filters.find(params[:id])
+ end
+
+ def resource_params
+ params.permit(:phrase, :expires_at, :irreversible, context: [])
+ end
+end
diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb
new file mode 100644
index 0000000000..03403a1ba1
--- /dev/null
+++ b/app/controllers/filters_controller.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class FiltersController < ApplicationController
+ include Authorization
+
+ layout 'admin'
+
+ before_action :set_filters, only: :index
+ before_action :set_filter, only: [:edit, :update, :destroy]
+
+ def index
+ @filters = current_account.custom_filters
+ end
+
+ def new
+ @filter = current_account.custom_filters.build
+ end
+
+ def create
+ @filter = current_account.custom_filters.build(resource_params)
+
+ if @filter.save
+ redirect_to filters_path
+ else
+ render action: :new
+ end
+ end
+
+ def edit; end
+
+ def update
+ if @filter.update(resource_params)
+ redirect_to filters_path
+ else
+ render action: :edit
+ end
+ end
+
+ def destroy
+ @filter.destroy
+ redirect_to filters_path
+ end
+
+ private
+
+ def set_filters
+ @filters = current_account.custom_filters
+ end
+
+ def set_filter
+ @filter = current_account.custom_filters.find(params[:id])
+ end
+
+ def resource_params
+ params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, context: [])
+ end
+end
diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js
new file mode 100644
index 0000000000..7fa1c9a70d
--- /dev/null
+++ b/app/javascript/mastodon/actions/filters.js
@@ -0,0 +1,26 @@
+import api from '../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,
+ }));
+};
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index f56853bffb..32fc67e67f 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -6,6 +6,7 @@ import {
disconnectTimeline,
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
+import { fetchFilters } from './filters';
import { getLocale } from '../locales';
const { messages } = getLocale();
@@ -30,6 +31,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
case 'notification':
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break;
+ case 'filters_changed':
+ dispatch(fetchFilters());
+ break;
}
},
};
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index fd08ff3b7c..922b609ece 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -157,6 +157,21 @@ export default class Status extends ImmutablePureComponent {
);
}
+ if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
+ const minHandlers = this.props.muted ? {} : {
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+
if (featured) {
prepend = (
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 1c34d06408..68c9eef54a 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -25,6 +25,7 @@ export default class StatusList extends ImmutablePureComponent {
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
alwaysPrepend: PropTypes.bool,
+ timelineId: PropTypes.string.isRequired,
};
static defaultProps = {
@@ -70,7 +71,7 @@ export default class StatusList extends ImmutablePureComponent {
}
render () {
- const { statusIds, featuredStatusIds, onLoadMore, ...other } = this.props;
+ const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props;
const { isLoading, isPartial } = other;
if (isPartial) {
@@ -102,6 +103,7 @@ export default class StatusList extends ImmutablePureComponent {
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
+ contextType={timelineId}
/>
))
) : null;
@@ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent {
featured
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
+ contextType={timelineId}
/>
)).concat(scrollableContent);
}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 3e7b5215be..eb6329fdcd 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -42,7 +42,7 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => ({
- status: getStatus(state, props.id),
+ status: getStatus(state, props),
});
return mapStateToProps;
diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.js b/app/javascript/mastodon/features/community_timeline/components/column_settings.js
index 3a1d19aa81..f4325f58d3 100644
--- a/app/javascript/mastodon/features/community_timeline/components/column_settings.js
+++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.js
@@ -1,15 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import SettingText from '../../../components/setting_text';
+import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../notifications/components/setting_toggle';
-const messages = defineMessages({
- filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
- settings: { id: 'home.settings', defaultMessage: 'Column settings' },
-});
-
@injectIntl
export default class ColumnSettings extends React.PureComponent {
@@ -21,19 +15,13 @@ export default class ColumnSettings extends React.PureComponent {
};
render () {
- const { settings, onChange, intl } = this.props;
+ const { settings, onChange } = this.props;
return (
);
}
diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
index 73f394c1af..5eb1eb72a4 100644
--- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
+++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
@@ -7,7 +7,7 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => ({
- status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
+ status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }),
});
return mapStateToProps;
diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js
index fda57f69af..63dc41d9e7 100644
--- a/app/javascript/mastodon/features/direct_timeline/index.js
+++ b/app/javascript/mastodon/features/direct_timeline/index.js
@@ -7,7 +7,6 @@ import ColumnHeader from '../../components/column_header';
import { expandDirectTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from '../../actions/streaming';
const messages = defineMessages({
@@ -86,9 +85,7 @@ export default class DirectTimeline extends React.PureComponent {
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
- >
-
-
+ />
@@ -33,12 +27,6 @@ export default class ColumnSettings extends React.PureComponent {
} />
-
-
-
-
-
-
);
}
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index ca792043f6..3c66536d43 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -58,7 +58,7 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => {
- const status = getStatus(state, props.params.statusId);
+ const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
@@ -336,6 +336,7 @@ export default class Status extends ImmutablePureComponent {
id={id}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
+ contextType='thread'
/>
));
}
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
index e5b1edc4a0..3df5b7beac 100644
--- a/app/javascript/mastodon/features/ui/containers/status_list_container.js
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -11,15 +11,6 @@ const makeGetStatusIds = () => createSelector([
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
(state) => state.get('statuses'),
], (columnSettings, statusIds, statuses) => {
- const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
- let regex = null;
-
- try {
- regex = rawRegex && new RegExp(rawRegex, 'i');
- } catch (e) {
- // Bad regex, don't affect filters
- }
-
return statusIds.filter(id => {
if (id === null) return true;
@@ -34,11 +25,6 @@ const makeGetStatusIds = () => createSelector([
showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
}
- if (showStatus && regex && statusForId.get('account') !== me) {
- const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
- showStatus = !regex.test(searchIndex);
- }
-
return showStatus;
});
});
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 3c1a266e30..56a8562303 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -12,6 +12,7 @@ import { debounce } from 'lodash';
import { uploadCompose, resetCompose } from '../../actions/compose';
import { expandHomeTimeline } from '../../actions/timelines';
import { expandNotifications } from '../../actions/notifications';
+import { fetchFilters } from '../../actions/filters';
import { clearHeight } from '../../actions/height_cache';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
@@ -297,6 +298,7 @@ export default class UI extends React.PureComponent {
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());
+ setTimeout(() => this.props.dispatch(fetchFilters()), 500);
}
componentDidMount () {
diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js
new file mode 100644
index 0000000000..33f0c67328
--- /dev/null
+++ b/app/javascript/mastodon/reducers/filters.js
@@ -0,0 +1,11 @@
+import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
+import { List as ImmutableList, fromJS } from 'immutable';
+
+export default function filters(state = ImmutableList(), action) {
+ switch(action.type) {
+ case FILTERS_FETCH_SUCCESS:
+ return fromJS(action.filters);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3d9a6a1329..4a981fada9 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -26,6 +26,7 @@ import height_cache from './height_cache';
import custom_emojis from './custom_emojis';
import lists from './lists';
import listEditor from './list_editor';
+import filters from './filters';
const reducers = {
dropdown_menu,
@@ -55,6 +56,7 @@ const reducers = {
custom_emojis,
lists,
listEditor,
+ filters,
};
export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index e47ec5183c..56eca1f02a 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -19,16 +19,44 @@ export const makeGetAccount = () => {
});
};
+const toServerSideType = columnType => {
+ switch (columnType) {
+ case 'home':
+ case 'notifications':
+ case 'public':
+ case 'thread':
+ return columnType;
+ default:
+ if (columnType.indexOf('list:') > -1) {
+ return 'home';
+ } else {
+ return 'public'; // community, account, hashtag
+ }
+ }
+};
+
+const escapeRegExp = string =>
+ string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
+
+const regexFromFilters = filters => {
+ if (filters.size === 0) {
+ return null;
+ }
+
+ return new RegExp(filters.map(filter => escapeRegExp(filter.get('phrase'))).join('|'), 'i');
+};
+
export const makeGetStatus = () => {
return createSelector(
[
- (state, id) => state.getIn(['statuses', id]),
- (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, { id }) => state.getIn(['statuses', id]),
+ (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, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))),
],
- (statusBase, statusReblog, accountBase, accountReblog) => {
+ (statusBase, statusReblog, accountBase, accountReblog, filters) => {
if (!statusBase) {
return null;
}
@@ -39,9 +67,13 @@ export const makeGetStatus = () => {
statusReblog = null;
}
+ const regex = regexFromFilters(filters);
+ const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'));
+
return statusBase.withMutations(map => {
map.set('reblog', statusReblog);
map.set('account', accountBase);
+ map.set('filtered', filtered);
});
}
);
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index c16cf3437b..5fa73d58a2 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -725,6 +725,20 @@
vertical-align: middle;
}
+.status__wrapper--filtered {
+ color: $dark-text-color;
+ border: 0;
+ font-size: inherit;
+ text-align: center;
+ line-height: inherit;
+ margin: 0;
+ padding: 15px;
+ box-sizing: border-box;
+ width: 100%;
+ clear: both;
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
.status__prepend-icon-wrapper {
left: -26px;
position: absolute;
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index c18c07b338..ee9185d344 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -153,6 +153,7 @@ class FeedManager
def filter_from_home?(status, receiver_id)
return false if receiver_id == status.account_id
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
+ return true if phrase_filtered?(status, receiver_id, :home)
check_for_blocks = status.mentions.pluck(:account_id)
check_for_blocks.concat([status.account_id])
@@ -177,6 +178,7 @@ class FeedManager
def filter_from_mentions?(status, receiver_id)
return true if receiver_id == status.account_id
+ return true if phrase_filtered?(status, receiver_id, :notifications)
# This filter is called from NotifyService, but already after the sender of
# the notification has been checked for mute/block. Therefore, it's not
@@ -190,6 +192,20 @@ class FeedManager
should_filter
end
+ def phrase_filtered?(status, receiver_id, context)
+ active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a
+
+ active_filters.select! { |filter| filter.context.include?(context.to_s) && !filter.expired? }
+ active_filters.map! { |filter| Regexp.new(Regexp.escape(filter.phrase), true) }
+
+ return false if active_filters.empty?
+
+ combined_regex = active_filters.reduce { |memo, obj| Regexp.union(memo, obj) }
+
+ !combined_regex.match(status.text).nil? ||
+ (status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?)
+ end
+
# Adds a status to an account's feed, returning true if a status was
# added, and false if it was not added to the feed. Note that this is
# an internal helper: callers must call trim or push updates if
diff --git a/app/models/account.rb b/app/models/account.rb
index c3eea79cc7..40a45b1f88 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -99,6 +99,7 @@ class Account < ApplicationRecord
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id
has_many :report_notes, dependent: :destroy
+ has_many :custom_filters, inverse_of: :account, dependent: :destroy
# Moderation notes
has_many :account_moderation_notes, dependent: :destroy
diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb
new file mode 100644
index 0000000000..444ccdfdbe
--- /dev/null
+++ b/app/models/concerns/expireable.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Expireable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
+
+ attr_reader :expires_in
+
+ def expires_in=(interval)
+ self.expires_at = interval.to_i.seconds.from_now unless interval.blank?
+ @expires_in = interval
+ end
+
+ def expire!
+ touch(:expires_at)
+ end
+
+ def expired?
+ !expires_at.nil? && expires_at < Time.now.utc
+ end
+ end
+end
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
new file mode 100644
index 0000000000..2c1a54375f
--- /dev/null
+++ b/app/models/custom_filter.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: custom_filters
+#
+# id :bigint(8) not null, primary key
+# account_id :bigint(8)
+# expires_at :datetime
+# phrase :text default(""), not null
+# context :string default([]), not null, is an Array
+# irreversible :boolean default(FALSE), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class CustomFilter < ApplicationRecord
+ VALID_CONTEXTS = %w(
+ home
+ notifications
+ public
+ thread
+ ).freeze
+
+ include Expireable
+
+ belongs_to :account
+
+ validates :phrase, :context, presence: true
+ validate :context_must_be_valid
+ validate :irreversible_must_be_within_context
+
+ scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) }
+
+ before_validation :clean_up_contexts
+ after_commit :remove_cache
+
+ private
+
+ def clean_up_contexts
+ self.context = Array(context).map(&:strip).map(&:presence).compact
+ end
+
+ def remove_cache
+ Rails.cache.delete("filters:#{account_id}")
+ Redis.current.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed))
+ end
+
+ def context_must_be_valid
+ errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) }
+ end
+
+ def irreversible_must_be_within_context
+ errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications')
+ end
+end
diff --git a/app/models/invite.rb b/app/models/invite.rb
index d0cc427c45..fe23224625 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -15,33 +15,19 @@
#
class Invite < ApplicationRecord
+ include Expireable
+
belongs_to :user
has_many :users, inverse_of: :invite
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
- scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
before_validation :set_code
- attr_reader :expires_in
-
- def expires_in=(interval)
- self.expires_at = interval.to_i.seconds.from_now unless interval.blank?
- @expires_in = interval
- end
-
def valid_for_use?
(max_uses.nil? || uses < max_uses) && !expired?
end
- def expire!
- touch(:expires_at)
- end
-
- def expired?
- !expires_at.nil? && expires_at < Time.now.utc
- end
-
private
def set_code
diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb
new file mode 100644
index 0000000000..07f2516f8a
--- /dev/null
+++ b/app/serializers/rest/filter_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::FilterSerializer < ActiveModel::Serializer
+ attributes :id, :phrase, :context, :expires_at
+end
diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml
new file mode 100644
index 0000000000..af5d648b80
--- /dev/null
+++ b/app/views/filters/_fields.html.haml
@@ -0,0 +1,11 @@
+.fields-group
+ = f.input :phrase, as: :string, wrapper: :with_block_label
+
+.fields-group
+ = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false
+
+.fields-group
+ = f.input :irreversible, wrapper: :with_label
+
+.fields-group
+ = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
diff --git a/app/views/filters/edit.html.haml b/app/views/filters/edit.html.haml
new file mode 100644
index 0000000000..e971215ac6
--- /dev/null
+++ b/app/views/filters/edit.html.haml
@@ -0,0 +1,8 @@
+- content_for :page_title do
+ = t('filters.edit.title')
+
+= simple_form_for @filter, url: filter_path(@filter), method: :put do |f|
+ = render 'fields', f: f
+
+ .actions
+ = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/filters/index.html.haml b/app/views/filters/index.html.haml
new file mode 100644
index 0000000000..18ebee5707
--- /dev/null
+++ b/app/views/filters/index.html.haml
@@ -0,0 +1,20 @@
+- content_for :page_title do
+ = t('filters.index.title')
+
+.table-wrapper
+ %table.table
+ %thead
+ %tr
+ %th= t('simple_form.labels.defaults.phrase')
+ %th= t('simple_form.labels.defaults.context')
+ %th
+ %tbody
+ - @filters.each do |filter|
+ %tr
+ %td= filter.phrase
+ %td= filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ')
+ %td
+ = table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter)
+ = table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete
+
+= link_to t('filters.new.title'), new_filter_path, class: 'button'
diff --git a/app/views/filters/new.html.haml b/app/views/filters/new.html.haml
new file mode 100644
index 0000000000..05bec343f8
--- /dev/null
+++ b/app/views/filters/new.html.haml
@@ -0,0 +1,8 @@
+- content_for :page_title do
+ = t('filters.new.title')
+
+= simple_form_for @filter, url: filters_path do |f|
+ = render 'fields', f: f
+
+ .actions
+ = f.button :button, t('filters.new.title'), type: :submit
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 01e5dd2f8d..5cb81ebe9e 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -474,6 +474,22 @@ en:
follows: You follow
mutes: You mute
storage: Media storage
+ filters:
+ contexts:
+ home: Home timeline
+ notifications: Notifications
+ public: Public timelines
+ thread: Conversations
+ edit:
+ title: Edit filter
+ errors:
+ invalid_context: None or invalid context supplied
+ invalid_irreversible: Irreversible filtering only works with home or notifications context
+ index:
+ delete: Delete
+ title: Filters
+ new:
+ title: Add new filter
followers:
domain: Domain
explanation_html: If you want to ensure the privacy of your statuses, you must be aware of who is following you. Your private statuses are delivered to all instances where you have followers. You may wish to review them, and remove followers if you do not trust your privacy to be respected by the staff or software of those instances.
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 6783f00452..59133ea733 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -6,17 +6,20 @@ en:
autofollow: People who sign up through the invite will automatically follow you
avatar: PNG, GIF or JPG. At most 2MB. Will be downscaled to 400x400px
bot: This account mainly performs automated actions and might not be monitored
+ context: One or multiple contexts where the filter should apply
digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
display_name:
one: 1 character left
other: %{count} characters left
fields: You can have up to 4 items displayed as a table on your profile
header: PNG, GIF or JPG. At most 2MB. Will be downscaled to 700x335px
+ irreversible: Filtered toots will disappear irreversibly, even if filter is later removed
locale: The language of the user interface, e-mails and push notifications
locked: Requires you to manually approve followers
note:
one: 1 character left
other: %{count} characters left
+ phrase: Will be matched regardless of casing in text or content warning of a toot
setting_default_language: The language of your toots can be detected automatically, but it's not always accurate
setting_hide_network: Who you follow and who follows you will not be shown on your profile
setting_noindex: Affects your public profile and status pages
@@ -39,6 +42,7 @@ en:
chosen_languages: Filter languages
confirm_new_password: Confirm new password
confirm_password: Confirm password
+ context: Filter contexts
current_password: Current password
data: Data
display_name: Display name
@@ -46,6 +50,7 @@ en:
expires_in: Expire after
fields: Profile metadata
header: Header
+ irreversible: Drop instead of hide
locale: Interface language
locked: Lock account
max_uses: Max number of uses
@@ -53,6 +58,7 @@ en:
note: Bio
otp_attempt: Two-factor code
password: Password
+ phrase: Keyword or phrase
setting_auto_play_gif: Auto-play animated GIFs
setting_boost_modal: Show confirmation dialog before boosting
setting_default_language: Posting language
diff --git a/config/navigation.rb b/config/navigation.rb
index 2bee5a4f96..3f2e913c62 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -16,6 +16,7 @@ SimpleNavigation::Configuration.run do |navigation|
settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url
end
+ primary.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}
primary.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' }
primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development|
diff --git a/config/routes.rb b/config/routes.rb
index a3cba24fcd..5fdd3b390e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -114,6 +114,7 @@ Rails.application.routes.draw do
resources :tags, only: [:show]
resources :emojis, only: [:show]
resources :invites, only: [:index, :create, :destroy]
+ resources :filters, except: [:show]
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy
@@ -254,6 +255,7 @@ Rails.application.routes.draw do
resources :mutes, only: [:index]
resources :favourites, only: [:index]
resources :reports, only: [:index, :create]
+ resources :filters, only: [:index, :create, :show, :update, :destroy]
namespace :apps do
get :verify_credentials, to: 'credentials#show'
diff --git a/db/migrate/20180628181026_create_custom_filters.rb b/db/migrate/20180628181026_create_custom_filters.rb
new file mode 100644
index 0000000000..d19cf2e9d6
--- /dev/null
+++ b/db/migrate/20180628181026_create_custom_filters.rb
@@ -0,0 +1,13 @@
+class CreateCustomFilters < ActiveRecord::Migration[5.2]
+ def change
+ create_table :custom_filters do |t|
+ t.belongs_to :account, foreign_key: { on_delete: :cascade }
+ t.datetime :expires_at
+ t.text :phrase, null: false, default: ''
+ t.string :context, array: true, null: false, default: []
+ t.boolean :irreversible, null: false, default: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2853aef942..661fc81793 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2018_06_17_162849) do
+ActiveRecord::Schema.define(version: 2018_06_28_181026) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -143,6 +143,17 @@ ActiveRecord::Schema.define(version: 2018_06_17_162849) do
t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
end
+ create_table "custom_filters", force: :cascade do |t|
+ t.bigint "account_id"
+ t.datetime "expires_at"
+ t.text "phrase", default: "", null: false
+ t.string "context", default: [], null: false, array: true
+ t.boolean "irreversible", default: false, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_custom_filters_on_account_id"
+ end
+
create_table "domain_blocks", force: :cascade do |t|
t.string "domain", default: "", null: false
t.datetime "created_at", null: false
@@ -561,6 +572,7 @@ ActiveRecord::Schema.define(version: 2018_06_17_162849) do
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
+ add_foreign_key "custom_filters", "accounts", on_delete: :cascade
add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
diff --git a/spec/controllers/api/v1/filter_controller_spec.rb b/spec/controllers/api/v1/filter_controller_spec.rb
new file mode 100644
index 0000000000..3ffd8f784a
--- /dev/null
+++ b/spec/controllers/api/v1/filter_controller_spec.rb
@@ -0,0 +1,81 @@
+require 'rails_helper'
+
+RSpec.describe Api::V1::FiltersController, type: :controller do
+ render_views
+
+ let(:user) { Fabricate(:user) }
+ let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') }
+
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ end
+
+ describe 'GET #index' do
+ let!(:filter) { Fabricate(:custom_filter, account: user.account) }
+
+ it 'returns http success' do
+ get :index
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ describe 'POST #create' do
+ before do
+ post :create, params: { phrase: 'magic', context: %w(home), irreversible: true }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'creates a filter' do
+ filter = user.account.custom_filters.first
+ expect(filter).to_not be_nil
+ expect(filter.phrase).to eq 'magic'
+ expect(filter.context).to eq %w(home)
+ expect(filter.irreversible?).to be true
+ expect(filter.expires_at).to be_nil
+ end
+ end
+
+ describe 'GET #show' do
+ let(:filter) { Fabricate(:custom_filter, account: user.account) }
+
+ it 'returns http success' do
+ get :show, params: { id: filter.id }
+ expect(response).to have_http_status(200)
+ end
+ end
+
+ describe 'PUT #update' do
+ let(:filter) { Fabricate(:custom_filter, account: user.account) }
+
+ before do
+ put :update, params: { id: filter.id, phrase: 'updated' }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'updates the filter' do
+ expect(filter.reload.phrase).to eq 'updated'
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ let(:filter) { Fabricate(:custom_filter, account: user.account) }
+
+ before do
+ delete :destroy, params: { id: filter.id }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'removes the filter' do
+ expect { filter.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
+ end
+end
diff --git a/spec/fabricators/custom_filter_fabricator.rb b/spec/fabricators/custom_filter_fabricator.rb
new file mode 100644
index 0000000000..64297a7e30
--- /dev/null
+++ b/spec/fabricators/custom_filter_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:custom_filter) do
+ account
+ expires_at nil
+ phrase 'discourse'
+ context %w(home notifications)
+end
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 6ead5bbd93..d1b8476755 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -126,6 +126,14 @@ RSpec.describe FeedManager do
reblog = Fabricate(:status, reblog: status, account: jeff)
expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
end
+
+ it 'returns true if status contains irreversibly muted phrase' do
+ alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true)
+ alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
+ alice.follow!(jeff)
+ status = Fabricate(:status, text: 'i sure like POP TARts', account: jeff)
+ expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+ end
end
context 'for mentions feed' do
diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb
new file mode 100644
index 0000000000..1024542e7b
--- /dev/null
+++ b/spec/models/custom_filter_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe CustomFilter, type: :model do
+
+end