diff --git a/app/controllers/settings/keyword_mutes_controller.rb b/app/controllers/settings/keyword_mutes_controller.rb
new file mode 100644
index 0000000000..f79e1b320b
--- /dev/null
+++ b/app/controllers/settings/keyword_mutes_controller.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+class Settings::KeywordMutesController < ApplicationController
+ layout 'admin'
+
+ before_action :authenticate_user!
+ before_action :load_keyword_mute, only: [:edit, :update, :destroy]
+
+ def index
+ @keyword_mutes = paginated_keyword_mutes_for_account
+ end
+
+ def new
+ @keyword_mute = keyword_mutes_for_account.build
+ end
+
+ def create
+ @keyword_mute = keyword_mutes_for_account.create(keyword_mute_params)
+
+ if @keyword_mute.persisted?
+ redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+ else
+ render :new
+ end
+ end
+
+ def update
+ if @keyword_mute.update(keyword_mute_params)
+ redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+ else
+ render :edit
+ end
+ end
+
+ def destroy
+ @keyword_mute.destroy!
+
+ redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+ end
+
+ def destroy_all
+ keyword_mutes_for_account.delete_all
+
+ redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+ end
+
+ private
+
+ def keyword_mutes_for_account
+ Glitch::KeywordMute.where(account: current_account)
+ end
+
+ def load_keyword_mute
+ @keyword_mute = keyword_mutes_for_account.find(params[:id])
+ end
+
+ def keyword_mute_params
+ params.require(:keyword_mute).permit(:keyword, :whole_word)
+ end
+
+ def paginated_keyword_mutes_for_account
+ keyword_mutes_for_account.order(:keyword).page params[:page]
+ end
+end
diff --git a/app/helpers/settings/keyword_mutes_helper.rb b/app/helpers/settings/keyword_mutes_helper.rb
new file mode 100644
index 0000000000..7b98cd59e0
--- /dev/null
+++ b/app/helpers/settings/keyword_mutes_helper.rb
@@ -0,0 +1,2 @@
+module Settings::KeywordMutesHelper
+end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index ca15745cb1..2ddfac3366 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -141,6 +141,8 @@ class FeedManager
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 keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))
+
check_for_mutes = [status.account_id]
check_for_mutes.concat(status.mentions.pluck(:account_id))
check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
@@ -166,6 +168,18 @@ class FeedManager
false
end
+ def keyword_filter?(status, matcher)
+ should_filter = matcher =~ status.text
+ should_filter ||= matcher =~ status.spoiler_text
+
+ if status.reblog?
+ should_filter ||= matcher =~ status.reblog.text
+ should_filter ||= matcher =~ status.reblog.spoiler_text
+ end
+
+ !!should_filter
+ end
+
def filter_from_mentions?(status, receiver_id)
return true if receiver_id == status.account_id
@@ -175,6 +189,7 @@ class FeedManager
should_filter = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
+ should_filter ||= keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id)) # or if the mention contains a muted keyword
should_filter
end
diff --git a/app/models/glitch.rb b/app/models/glitch.rb
new file mode 100644
index 0000000000..0e497babcc
--- /dev/null
+++ b/app/models/glitch.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Glitch
+ def self.table_name_prefix
+ 'glitch_'
+ end
+end
diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb
new file mode 100644
index 0000000000..73de4d4b75
--- /dev/null
+++ b/app/models/glitch/keyword_mute.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: glitch_keyword_mutes
+#
+# id :integer not null, primary key
+# account_id :integer not null
+# keyword :string not null
+# whole_word :boolean default(TRUE), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class Glitch::KeywordMute < ApplicationRecord
+ belongs_to :account, required: true
+
+ validates_presence_of :keyword
+
+ after_commit :invalidate_cached_matcher
+
+ def self.matcher_for(account_id)
+ Matcher.new(account_id)
+ end
+
+ private
+
+ def invalidate_cached_matcher
+ Rails.cache.delete("keyword_mutes:regex:#{account_id}")
+ end
+
+ class Matcher
+ attr_reader :account_id
+ attr_reader :regex
+
+ def initialize(account_id)
+ @account_id = account_id
+ regex_text = Rails.cache.fetch("keyword_mutes:regex:#{account_id}") { regex_text_for_account }
+ @regex = /#{regex_text}/i
+ end
+
+ def =~(str)
+ regex =~ str
+ end
+
+ private
+
+ def keywords
+ Glitch::KeywordMute.where(account_id: account_id).select(:keyword, :id, :whole_word)
+ end
+
+ def regex_text_for_account
+ kws = keywords.find_each.with_object([]) do |kw, a|
+ a << (kw.whole_word ? boundary_regex_for_keyword(kw.keyword) : kw.keyword)
+ end
+
+ Regexp.union(kws).source
+ end
+
+ def boundary_regex_for_keyword(keyword)
+ sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
+ eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
+
+ /#{sb}#{Regexp.escape(keyword)}#{eb}/
+ end
+ end
+end
diff --git a/app/views/settings/keyword_mutes/_fields.html.haml b/app/views/settings/keyword_mutes/_fields.html.haml
new file mode 100644
index 0000000000..892676f180
--- /dev/null
+++ b/app/views/settings/keyword_mutes/_fields.html.haml
@@ -0,0 +1,11 @@
+.fields-group
+ = f.input :keyword
+ = f.check_box :whole_word
+ = f.label :whole_word, t('keyword_mutes.match_whole_word')
+
+.actions
+ - if f.object.persisted?
+ = f.button :button, t('generic.save_changes'), type: :submit
+ = link_to t('keyword_mutes.remove'), settings_keyword_mute_path(f.object), class: 'negative button', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
+ - else
+ = f.button :button, t('keyword_mutes.add_keyword'), type: :submit
diff --git a/app/views/settings/keyword_mutes/_keyword_mute.html.haml b/app/views/settings/keyword_mutes/_keyword_mute.html.haml
new file mode 100644
index 0000000000..c45cc64fbf
--- /dev/null
+++ b/app/views/settings/keyword_mutes/_keyword_mute.html.haml
@@ -0,0 +1,10 @@
+%tr
+ %td
+ = keyword_mute.keyword
+ %td
+ - if keyword_mute.whole_word
+ %i.fa.fa-check
+ %td
+ = table_link_to 'edit', t('keyword_mutes.edit'), edit_settings_keyword_mute_path(keyword_mute)
+ %td
+ = table_link_to 'times', t('keyword_mutes.remove'), settings_keyword_mute_path(keyword_mute), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/settings/keyword_mutes/edit.html.haml b/app/views/settings/keyword_mutes/edit.html.haml
new file mode 100644
index 0000000000..af3949be2a
--- /dev/null
+++ b/app/views/settings/keyword_mutes/edit.html.haml
@@ -0,0 +1,6 @@
+- content_for :page_title do
+ = t('keyword_mutes.edit_keyword')
+
+= simple_form_for @keyword_mute, url: settings_keyword_mute_path(@keyword_mute), as: :keyword_mute do |f|
+ = render 'shared/error_messages', object: @keyword_mute
+ = render 'fields', f: f
diff --git a/app/views/settings/keyword_mutes/index.html.haml b/app/views/settings/keyword_mutes/index.html.haml
new file mode 100644
index 0000000000..9ef8d55bc7
--- /dev/null
+++ b/app/views/settings/keyword_mutes/index.html.haml
@@ -0,0 +1,18 @@
+- content_for :page_title do
+ = t('settings.keyword_mutes')
+
+.table-wrapper
+ %table.table
+ %thead
+ %tr
+ %th= t('keyword_mutes.keyword')
+ %th= t('keyword_mutes.match_whole_word')
+ %th
+ %th
+ %tbody
+ = render partial: 'keyword_mute', collection: @keyword_mutes, as: :keyword_mute
+
+= paginate @keyword_mutes
+.simple_form
+ = link_to t('keyword_mutes.add_keyword'), new_settings_keyword_mute_path, class: 'button'
+ = link_to t('keyword_mutes.remove_all'), destroy_all_settings_keyword_mutes_path, class: 'button negative', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/settings/keyword_mutes/new.html.haml b/app/views/settings/keyword_mutes/new.html.haml
new file mode 100644
index 0000000000..5c999c8d2e
--- /dev/null
+++ b/app/views/settings/keyword_mutes/new.html.haml
@@ -0,0 +1,6 @@
+- content_for :page_title do
+ = t('keyword_mutes.add_keyword')
+
+= simple_form_for @keyword_mute, url: settings_keyword_mutes_path, as: :keyword_mute do |f|
+ = render 'shared/error_messages', object: @keyword_mute
+ = render 'fields', f: f
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 45929e97dd..7d46df3278 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -373,6 +373,14 @@ en:
following: Following list
muting: Muting list
upload: Upload
+ keyword_mutes:
+ add_keyword: Add keyword
+ edit: Edit
+ edit_keyword: Edit keyword
+ keyword: Keyword
+ match_whole_word: Match whole word
+ remove: Remove
+ remove_all: Remove all
landing_strip_html: "%{name} is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse."
landing_strip_signup_html: If you don't, you can sign up here.
media_attachments:
@@ -491,6 +499,7 @@ en:
export: Data export
followers: Authorized followers
import: Import
+ keyword_mutes: Muted keywords
notifications: Notifications
preferences: Preferences
settings: Settings
diff --git a/config/navigation.rb b/config/navigation.rb
index 50bfbd4801..9fa029b72e 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -7,6 +7,7 @@ SimpleNavigation::Configuration.run do |navigation|
primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings|
settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url
settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url
+ settings.item :keyword_mutes, safe_join([fa_icon('volume-off fw'), t('settings.keyword_mutes')]), settings_keyword_mutes_url
settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url
settings.item :password, safe_join([fa_icon('lock fw'), t('auth.change_password')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}
settings.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication}
diff --git a/config/routes.rb b/config/routes.rb
index 047de73ba2..d014897257 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -66,6 +66,13 @@ Rails.application.routes.draw do
namespace :settings do
resource :profile, only: [:show, :update]
+
+ resources :keyword_mutes do
+ collection do
+ delete :destroy_all
+ end
+ end
+
resource :preferences, only: [:show, :update]
resource :notifications, only: [:show, :update]
resource :import, only: [:show, :create]
diff --git a/db/migrate/20171009222537_create_keyword_mutes.rb b/db/migrate/20171009222537_create_keyword_mutes.rb
new file mode 100644
index 0000000000..ec0c756fbf
--- /dev/null
+++ b/db/migrate/20171009222537_create_keyword_mutes.rb
@@ -0,0 +1,12 @@
+class CreateKeywordMutes < ActiveRecord::Migration[5.1]
+ def change
+ create_table :keyword_mutes do |t|
+ t.references :account, null: false
+ t.string :keyword, null: false
+ t.boolean :whole_word, null: false, default: true
+ t.timestamps
+ end
+
+ add_foreign_key :keyword_mutes, :accounts, on_delete: :cascade
+ end
+end
diff --git a/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb
new file mode 100644
index 0000000000..269bb49d64
--- /dev/null
+++ b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb
@@ -0,0 +1,7 @@
+class MoveKeywordMutesIntoGlitchNamespace < ActiveRecord::Migration[5.1]
+ def change
+ safety_assured do
+ rename_table :keyword_mutes, :glitch_keyword_mutes
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 128f51ee7e..c09876c4d7 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: 20171010025614) do
+ActiveRecord::Schema.define(version: 20171021191900) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -155,6 +155,15 @@ ActiveRecord::Schema.define(version: 20171010025614) do
t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true
end
+ create_table "glitch_keyword_mutes", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.string "keyword", null: false
+ t.boolean "whole_word", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_glitch_keyword_mutes_on_account_id"
+ end
+
create_table "imports", force: :cascade do |t|
t.integer "type", null: false
t.boolean "approved", default: false, null: false
@@ -472,6 +481,7 @@ ActiveRecord::Schema.define(version: 20171010025614) do
add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade
add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
+ add_foreign_key "glitch_keyword_mutes", "accounts", on_delete: :cascade
add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify
add_foreign_key "media_attachments", "statuses", on_delete: :nullify
diff --git a/spec/controllers/settings/keyword_mutes_controller_spec.rb b/spec/controllers/settings/keyword_mutes_controller_spec.rb
new file mode 100644
index 0000000000..a8c37a072f
--- /dev/null
+++ b/spec/controllers/settings/keyword_mutes_controller_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Settings::KeywordMutesController, type: :controller do
+
+end
diff --git a/spec/fabricators/glitch_keyword_mute_fabricator.rb b/spec/fabricators/glitch_keyword_mute_fabricator.rb
new file mode 100644
index 0000000000..20d393320d
--- /dev/null
+++ b/spec/fabricators/glitch_keyword_mute_fabricator.rb
@@ -0,0 +1,2 @@
+Fabricator('Glitch::KeywordMute') do
+end
diff --git a/spec/helpers/settings/keyword_mutes_helper_spec.rb b/spec/helpers/settings/keyword_mutes_helper_spec.rb
new file mode 100644
index 0000000000..a19d518ddd
--- /dev/null
+++ b/spec/helpers/settings/keyword_mutes_helper_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+# Specs in this file have access to a helper object that includes
+# the Settings::KeywordMutesHelper. For example:
+#
+# describe Settings::KeywordMutesHelper do
+# describe "string concat" do
+# it "concats two strings with spaces" do
+# expect(helper.concat_strings("this","that")).to eq("this that")
+# end
+# end
+# end
+RSpec.describe Settings::KeywordMutesHelper, type: :helper do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 1861cc6edf..e678d3ca4a 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -119,6 +119,44 @@ 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 for a status containing a muted keyword' do
+ Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+ status = Fabricate(:status, text: 'This is a hot take', account: bob)
+
+ expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+ end
+
+ it 'returns true for a reply containing a muted keyword' do
+ Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+ s1 = Fabricate(:status, text: 'Something', account: alice)
+ s2 = Fabricate(:status, text: 'This is a hot take', thread: s1, account: bob)
+
+ expect(FeedManager.instance.filter?(:home, s2, alice.id)).to be true
+ end
+
+ it 'returns true for a status whose spoiler text contains a muted keyword' do
+ Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+ status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob)
+
+ expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+ end
+
+ it 'returns true for a reblog containing a muted keyword' do
+ Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+ status = Fabricate(:status, text: 'This is a hot take', account: bob)
+ reblog = Fabricate(:status, reblog: status, account: jeff)
+
+ expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
+ end
+
+ it 'returns true for a reblog whose spoiler text contains a muted keyword' do
+ Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+ status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob)
+ reblog = Fabricate(:status, reblog: status, account: jeff)
+
+ expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
+ end
end
context 'for mentions feed' do
@@ -147,6 +185,13 @@ RSpec.describe FeedManager do
bob.follow!(alice)
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false
end
+
+ it 'returns true for status that contains a muted keyword' do
+ Fabricate('Glitch::KeywordMute', account: bob, keyword: 'take')
+ status = Fabricate(:status, text: 'This is a hot take', account: alice)
+ bob.follow!(alice)
+ expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
+ end
end
end
diff --git a/spec/models/glitch/keyword_mute_spec.rb b/spec/models/glitch/keyword_mute_spec.rb
new file mode 100644
index 0000000000..1423823bad
--- /dev/null
+++ b/spec/models/glitch/keyword_mute_spec.rb
@@ -0,0 +1,89 @@
+require 'rails_helper'
+
+RSpec.describe Glitch::KeywordMute, type: :model do
+ let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) }
+ let(:bob) { Fabricate(:account, username: 'bob').tap(&:save!) }
+
+ describe '.matcher_for' do
+ let(:matcher) { Glitch::KeywordMute.matcher_for(alice) }
+
+ describe 'with no mutes' do
+ before do
+ Glitch::KeywordMute.delete_all
+ end
+
+ it 'does not match' do
+ expect(matcher =~ 'This is a hot take').to be_falsy
+ end
+ end
+
+ describe 'with mutes' do
+ it 'does not match keywords set by a different account' do
+ Glitch::KeywordMute.create!(account: bob, keyword: 'take')
+
+ expect(matcher =~ 'This is a hot take').to be_falsy
+ end
+
+ it 'does not match if no keywords match the status text' do
+ Glitch::KeywordMute.create!(account: alice, keyword: 'cold')
+
+ expect(matcher =~ 'This is a hot take').to be_falsy
+ end
+
+ it 'considers word boundaries when matching' do
+ Glitch::KeywordMute.create!(account: alice, keyword: 'bob', whole_word: true)
+
+ expect(matcher =~ 'bobcats').to be_falsy
+ end
+
+ it 'matches substrings if whole_word is false' do
+ Glitch::KeywordMute.create!(account: alice, keyword: 'take', whole_word: false)
+
+ expect(matcher =~ 'This is a shiitake mushroom').to be_truthy
+ end
+
+ it 'matches keywords at the beginning of the text' do
+ Glitch::KeywordMute.create!(account: alice, keyword: 'take')
+
+ expect(matcher =~ 'Take this').to be_truthy
+ end
+
+ it 'matches keywords at the end of the text' do
+ Glitch::KeywordMute.create!(account: alice, keyword: 'take')
+
+ expect(matcher =~ 'This is a hot take').to be_truthy
+ end
+
+ it 'matches if at least one keyword case-insensitively matches the text' do
+ Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
+
+ expect(matcher =~ 'This is a HOT take').to be_truthy
+ end
+
+ it 'matches keywords surrounded by non-alphanumeric ornamentation' do
+ Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
+
+ expect(matcher =~ '(hot take)').to be_truthy
+ end
+
+ it 'escapes metacharacters in keywords' do
+ Glitch::KeywordMute.create!(account: alice, keyword: '(hot take)')
+
+ expect(matcher =~ '(hot take)').to be_truthy
+ end
+
+ it 'uses case-folding rules appropriate for more than just English' do
+ Glitch::KeywordMute.create!(account: alice, keyword: 'großeltern')
+
+ expect(matcher =~ 'besuch der grosseltern').to be_truthy
+ end
+
+ it 'matches keywords that are composed of multiple words' do
+ Glitch::KeywordMute.create!(account: alice, keyword: 'a shiitake')
+
+ expect(matcher =~ 'This is a shiitake').to be_truthy
+ expect(matcher =~ 'This is shiitake').to_not be_truthy
+ end
+ end
+ end
+end