commit
a2612d0d38
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
module Settings::KeywordMutesHelper
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Glitch
|
||||
def self.table_name_prefix
|
||||
'glitch_'
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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') }
|
|
@ -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
|
|
@ -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') }
|
|
@ -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
|
|
@ -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: "<strong>%{name}</strong> 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 <a href="%{sign_up_path}">sign up here</a>.
|
||||
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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
|
@ -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
|
12
db/schema.rb
12
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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Settings::KeywordMutesController, type: :controller do
|
||||
|
||||
end
|
|
@ -0,0 +1,2 @@
|
|||
Fabricator('Glitch::KeywordMute') do
|
||||
end
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
Reference in New Issue