From cdb101340a20183a82889f811d9311c370c855e5 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 29 Jun 2018 15:34:36 +0200 Subject: Keyword/phrase filtering (#7905) * Add keyword filtering GET|POST /api/v1/filters GET|PUT|DELETE /api/v1/filters/:id - Irreversible filters can drop toots from home or notifications - Other filters can hide toots through the client app - Filters use a phrase valid in particular contexts, expiration * Make sure expired filters don't get applied client-side * Add missing API methods * Remove "regex filter" from column settings * Add tests * Add test for FeedManager * Add CustomFilter test * Add UI for managing filters * Add streaming API event to allow syncing filters * Fix tests --- spec/models/custom_filter_spec.rb | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 spec/models/custom_filter_spec.rb (limited to 'spec/models') diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb new file mode 100644 index 000000000..1024542e7 --- /dev/null +++ b/spec/models/custom_filter_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe CustomFilter, type: :model do + +end -- cgit From da8fe8079e13758f45e5ba77cb8023c554ae193c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 3 Jul 2018 01:47:56 +0200 Subject: Re-add follow recommendations API (#7918) * Re-add follow recommendations API GET /api/v1/suggestions Removed in 8efa081f210d72ed450c39ac4cde0fd84fb3d3fb due to Neo4J dependency. The algorithm uses triadic closures, takes into account suspensions, blocks, mutes, domain blocks, excludes locked and moved accounts, and prefers more recently updated accounts. * Track interactions with people you don't follow Replying to, favouriting and reblogging someone you're not following will make them show up in follow recommendations. The interactions have different weights: - Replying is 1 - Favouriting is 10 (decidedly positive interaction, but private) - Reblogging is 20 Following them, muting or blocking will remove them from the list, obviously. * Remove triadic closures, ensure potential friendships are trimmed --- app/controllers/api/v1/suggestions_controller.rb | 21 +++++++ app/lib/potential_friendship_tracker.rb | 39 ++++++++++++ app/models/account.rb | 29 +-------- app/models/concerns/account_interactions.rb | 12 ++++ app/services/favourite_service.rb | 8 +++ app/services/post_status_service.rb | 7 +++ app/services/reblog_service.rb | 7 +++ config/routes.rb | 1 + .../api/v1/suggestions_controller_spec.rb | 35 +++++++++++ spec/models/account_spec.rb | 71 ---------------------- 10 files changed, 131 insertions(+), 99 deletions(-) create mode 100644 app/controllers/api/v1/suggestions_controller.rb create mode 100644 app/lib/potential_friendship_tracker.rb create mode 100644 spec/controllers/api/v1/suggestions_controller_spec.rb (limited to 'spec/models') diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb new file mode 100644 index 000000000..3abccedd5 --- /dev/null +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Api::V1::SuggestionsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + before_action :set_accounts + + respond_to :json + + def index + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def set_accounts + @accounts = PotentialFriendshipTracker.get(current_account.id, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT)) + end +end diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb new file mode 100644 index 000000000..362482669 --- /dev/null +++ b/app/lib/potential_friendship_tracker.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PotentialFriendshipTracker + EXPIRE_AFTER = 90.days.seconds + MAX_ITEMS = 80 + + WEIGHTS = { + reply: 1, + favourite: 10, + reblog: 20, + }.freeze + + class << self + def record(account_id, target_account_id, action) + key = "interactions:#{account_id}" + weight = WEIGHTS[action] + + redis.zincrby(key, weight, target_account_id) + redis.zremrangebyrank(key, 0, -MAX_ITEMS) + redis.expire(key, EXPIRE_AFTER) + end + + def remove(account_id, target_account_id) + redis.zrem("interactions:#{account_id}", target_account_id) + end + + def get(account_id, limit: 20, offset: 0) + account_ids = redis.zrevrange("interactions:#{account_id}", offset, limit) + return [] if account_ids.empty? + Account.searchable.where(id: account_ids) + end + + private + + def redis + Redis.current + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 40a45b1f8..1f720bf88 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -127,6 +127,7 @@ class Account < ApplicationRecord scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) } scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } + scope :searchable, -> { where(suspended: false).where(moved_to_account_id: nil) } delegate :email, :unconfirmed_email, @@ -309,34 +310,6 @@ class Account < ApplicationRecord DeliveryFailureTracker.filter(urls) end - def triadic_closures(account, limit: 5, offset: 0) - sql = <<-SQL.squish - WITH first_degree AS ( - SELECT target_account_id - FROM follows - WHERE account_id = :account_id - ) - SELECT accounts.* - FROM follows - INNER JOIN accounts ON follows.target_account_id = accounts.id - WHERE - account_id IN (SELECT * FROM first_degree) - AND target_account_id NOT IN (SELECT * FROM first_degree) - AND target_account_id NOT IN (:excluded_account_ids) - AND accounts.suspended = false - GROUP BY target_account_id, accounts.id - ORDER BY count(account_id) DESC - OFFSET :offset - LIMIT :limit - SQL - - excluded_account_ids = account.excluded_from_timeline_account_ids + [account.id] - - find_by_sql( - [sql, { account_id: account.id, excluded_account_ids: excluded_account_ids, limit: limit, offset: offset }] - ) - end - def search_for(terms, limit = 10) textsearch, query = generate_query_for_search(terms) diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index ef59f5d15..ee435f956 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -89,10 +89,13 @@ module AccountInteractions .find_or_create_by!(target_account: other_account) rel.update!(show_reblogs: reblogs) + remove_potential_friendship(other_account) + rel end def block!(other_account, uri: nil) + remove_potential_friendship(other_account) block_relationships.create_with(uri: uri) .find_or_create_by!(target_account: other_account) end @@ -100,10 +103,13 @@ module AccountInteractions def mute!(other_account, notifications: nil) notifications = true if notifications.nil? mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account) + remove_potential_friendship(other_account) + # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. if mute.hide_notifications? != notifications mute.update!(hide_notifications: notifications) end + mute end @@ -194,4 +200,10 @@ module AccountInteractions lists.joins(account: :user) .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago) end + + private + + def remove_potential_friendship(other_account) + PotentialFriendshipTracker.remove(id, other_account.id) + end end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index bc2d1547a..6e1ac3ba9 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -15,7 +15,10 @@ class FavouriteService < BaseService return favourite unless favourite.nil? favourite = Favourite.create!(account: account, status: status) + create_notification(favourite) + bump_potential_friendship(account, status) + favourite end @@ -33,6 +36,11 @@ class FavouriteService < BaseService end end + def bump_potential_friendship(account, status) + return if account.following?(status.account_id) + PotentialFriendshipTracker.record(account.id, status.account_id, :favourite) + end + def build_json(favourite) Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( favourite, diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 735985725..bad82051a 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -47,6 +47,8 @@ class PostStatusService < BaseService redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id) end + bump_potential_friendship(account, status) + status end @@ -79,4 +81,9 @@ class PostStatusService < BaseService def redis Redis.current end + + def bump_potential_friendship(account, status) + return if !status.reply? || account.following?(status.account_id) + PotentialFriendshipTracker.record(account.id, status.in_reply_to_account_id, :reply) + end end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 3c4e5847f..0ee8bac2f 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -24,6 +24,8 @@ class ReblogService < BaseService ActivityPub::DistributionWorker.perform_async(reblog.id) create_notification(reblog) + bump_potential_friendship(account, reblog) + reblog end @@ -41,6 +43,11 @@ class ReblogService < BaseService end end + def bump_potential_friendship(account, reblog) + return if account.following?(reblog.reblog.account_id) + PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog) + end + def build_json(reblog) Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( reblog, diff --git a/config/routes.rb b/config/routes.rb index 5fdd3b390..e59325964 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -246,6 +246,7 @@ Rails.application.routes.draw do resources :streaming, only: [:index] resources :custom_emojis, only: [:index] + resources :suggestions, only: [:index] get '/search', to: 'search#index', as: :search diff --git a/spec/controllers/api/v1/suggestions_controller_spec.rb b/spec/controllers/api/v1/suggestions_controller_spec.rb new file mode 100644 index 000000000..17f10b04f --- /dev/null +++ b/spec/controllers/api/v1/suggestions_controller_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe Api::V1::SuggestionsController, 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(:bob) { Fabricate(:account) } + let(:jeff) { Fabricate(:account) } + + before do + PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog) + PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite) + + get :index + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns accounts' do + json = body_as_json + + expect(json.size).to be >= 1 + expect(json.map { |i| i[:id] }).to include *[bob, jeff].map { |i| i.id.to_s } + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index cce659a8a..c50791bcd 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -454,77 +454,6 @@ RSpec.describe Account, type: :model do end end - describe '.triadic_closures' do - let!(:me) { Fabricate(:account) } - let!(:friend) { Fabricate(:account) } - let!(:friends_friend) { Fabricate(:account) } - let!(:both_follow) { Fabricate(:account) } - - before do - me.follow!(friend) - friend.follow!(friends_friend) - - me.follow!(both_follow) - friend.follow!(both_follow) - end - - it 'finds accounts you dont follow which are followed by accounts you do follow' do - expect(described_class.triadic_closures(me)).to eq [friends_friend] - end - - it 'limits by 5 with offset 0 by defualt' do - first_degree = 6.times.map { Fabricate(:account) } - matches = 5.times.map { Fabricate(:account) } - first_degree.each { |account| me.follow!(account) } - matches.each do |match| - first_degree.each { |account| account.follow!(match) } - first_degree.shift - end - - expect(described_class.triadic_closures(me)).to eq matches - end - - it 'accepts arbitrary limits' do - another_friend = Fabricate(:account) - higher_friends_friend = Fabricate(:account) - me.follow!(another_friend) - friend.follow!(higher_friends_friend) - another_friend.follow!(higher_friends_friend) - - expect(described_class.triadic_closures(me, limit: 1)).to eq [higher_friends_friend] - end - - it 'acceps arbitrary offset' do - another_friend = Fabricate(:account) - higher_friends_friend = Fabricate(:account) - me.follow!(another_friend) - friend.follow!(higher_friends_friend) - another_friend.follow!(higher_friends_friend) - - expect(described_class.triadic_closures(me, offset: 1)).to eq [friends_friend] - end - - context 'when you block account' do - before do - me.block!(friends_friend) - end - - it 'rejects blocked accounts' do - expect(described_class.triadic_closures(me)).to be_empty - end - end - - context 'when you mute account' do - before do - me.mute!(friends_friend) - end - - it 'rejects muted accounts' do - expect(described_class.triadic_closures(me)).to be_empty - end - end - end - describe '#statuses_count' do subject { Fabricate(:account) } -- cgit From 402da8065c2b378cca6361f2c7252bd766f25dde Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Sat, 7 Jul 2018 21:40:49 +0200 Subject: Migrate glitch-soc keyword mutes to Mastodon's Completely remove glitch-soc's Keyword Mutes, migrate existing database records to CustomFilters. Handling of client-side filters is still not implemented in the glitch-soc front-end. --- .../settings/keyword_mutes_controller.rb | 61 -------- app/lib/feed_manager.rb | 22 --- app/models/glitch.rb | 7 - app/models/glitch/keyword_mute.rb | 123 --------------- app/models/glitch/keyword_mute_helper.rb | 27 ---- config/navigation.rb | 1 - config/routes.rb | 6 - db/migrate/20180707193142_migrate_filters.rb | 54 +++++++ db/schema.rb | 13 +- .../settings/keyword_mutes_controller_spec.rb | 5 - spec/fabricators/glitch_keyword_mute_fabricator.rb | 2 - spec/lib/feed_manager_spec.rb | 76 --------- spec/models/glitch/keyword_mute_helper_spec.rb | 52 ------- spec/models/glitch/keyword_mute_spec.rb | 171 --------------------- 14 files changed, 55 insertions(+), 565 deletions(-) delete mode 100644 app/controllers/settings/keyword_mutes_controller.rb delete mode 100644 app/models/glitch.rb delete mode 100644 app/models/glitch/keyword_mute.rb delete mode 100644 app/models/glitch/keyword_mute_helper.rb create mode 100644 db/migrate/20180707193142_migrate_filters.rb delete mode 100644 spec/controllers/settings/keyword_mutes_controller_spec.rb delete mode 100644 spec/fabricators/glitch_keyword_mute_fabricator.rb delete mode 100644 spec/models/glitch/keyword_mute_helper_spec.rb delete mode 100644 spec/models/glitch/keyword_mute_spec.rb (limited to 'spec/models') diff --git a/app/controllers/settings/keyword_mutes_controller.rb b/app/controllers/settings/keyword_mutes_controller.rb deleted file mode 100644 index cf364a903..000000000 --- a/app/controllers/settings/keyword_mutes_controller.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -class Settings::KeywordMutesController < Settings::BaseController - 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, :apply_to_mentions) - end - - def paginated_keyword_mutes_for_account - keyword_mutes_for_account.order(:keyword).page params[:page] - end -end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index dff3cbb22..c247ab21d 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -153,7 +153,6 @@ 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 keyword_filter_from_home?(status, receiver_id) return true if phrase_filtered?(status, receiver_id, :home) check_for_blocks = status.mentions.pluck(:account_id) @@ -181,26 +180,6 @@ class FeedManager false end - def keyword_filter_from_home?(status, receiver_id) - # If this status mentions the receiver, use the mentions scope: it's - # possible that the status will show up in the receiver's mentions, which - # means it ought to show up in the home feed as well. - # - # If it doesn't mention the receiver but is still headed for the home feed, - # use the home feed scope. - scope = if status.mentions.pluck(:account_id).include?(receiver_id) - Glitch::KeywordMute::Scopes::Mentions - else - Glitch::KeywordMute::Scopes::HomeFeed - end - - return true if keyword_filter?(status, receiver_id, scope) - end - - def keyword_filter?(status, receiver_id, scope) - Glitch::KeywordMuteHelper.new(receiver_id).matches?(status, scope) - end - def filter_from_mentions?(status, receiver_id) return true if receiver_id == status.account_id return true if phrase_filtered?(status, receiver_id, :notifications) @@ -213,7 +192,6 @@ class FeedManager should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted) 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, receiver_id, Glitch::KeywordMute::Scopes::Mentions) # or if the mention contains a muted keyword should_filter end diff --git a/app/models/glitch.rb b/app/models/glitch.rb deleted file mode 100644 index 0e497babc..000000000 --- a/app/models/glitch.rb +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index 49769cb73..000000000 --- a/app/models/glitch/keyword_mute.rb +++ /dev/null @@ -1,123 +0,0 @@ -# frozen_string_literal: true -# == Schema Information -# -# Table name: glitch_keyword_mutes -# -# id :bigint(8) not null, primary key -# account_id :bigint(8) not null -# keyword :string not null -# whole_word :boolean default(TRUE), not null -# created_at :datetime not null -# updated_at :datetime not null -# apply_to_mentions :boolean default(TRUE), not null -# - -class Glitch::KeywordMute < ApplicationRecord - belongs_to :account, required: true - - validates_presence_of :keyword - - after_commit :invalidate_cached_matchers - - module Scopes - Unscoped = 0b00 - HomeFeed = 0b01 - Mentions = 0b10 - end - - def self.text_matcher_for(account_id) - TextMatcher.new(account_id) - end - - def self.tag_matcher_for(account_id) - TagMatcher.new(account_id) - end - - def scope - s = Scopes::Unscoped - s |= Scopes::HomeFeed - s |= Scopes::Mentions if apply_to_mentions? - s - end - - private - - def invalidate_cached_matchers - Rails.cache.delete(TextMatcher.cache_key(account_id)) - Rails.cache.delete(TagMatcher.cache_key(account_id)) - end - - class CachedKeywordMute - attr_reader :keyword - attr_reader :whole_word - attr_reader :scope - - def initialize(keyword, whole_word, scope) - @keyword = keyword - @whole_word = whole_word - @scope = scope - end - - def boundary_regex_for_keyword - sb = keyword =~ /\A[[:word:]]/ ? '\b' : '' - eb = keyword =~ /[[:word:]]\Z/ ? '\b' : '' - - /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/ - end - - def matches?(str, required_scope) - ((required_scope & scope) == required_scope) && \ - str =~ (whole_word ? boundary_regex_for_keyword : /#{Regexp.escape(keyword)}/i) - end - end - - class Matcher - attr_reader :account_id - attr_reader :keywords - - def initialize(account_id) - @account_id = account_id - @keywords = Rails.cache.fetch(self.class.cache_key(account_id)) { fetch_keywords } - end - - protected - - def fetch_keywords - Glitch::KeywordMute.select(:whole_word, :keyword, :apply_to_mentions) - .where(account_id: account_id) - .map { |kw| CachedKeywordMute.new(transform_keyword(kw.keyword), kw.whole_word, kw.scope) } - end - - def transform_keyword(keyword) - keyword - end - end - - class TextMatcher < Matcher - def self.cache_key(account_id) - format('keyword_mutes:regex:text:%s', account_id) - end - - def matches?(str, scope) - keywords.any? { |kw| kw.matches?(str, scope) } - end - end - - class TagMatcher < Matcher - def self.cache_key(account_id) - format('keyword_mutes:regex:tag:%s', account_id) - end - - def matches?(tags, scope) - tags.pluck(:name).any? do |n| - keywords.any? { |kw| kw.matches?(n, scope) } - end - end - - protected - - def transform_keyword(kw) - Tag::HASHTAG_RE =~ kw ? $1 : kw - end - end -end diff --git a/app/models/glitch/keyword_mute_helper.rb b/app/models/glitch/keyword_mute_helper.rb deleted file mode 100644 index 955c3b1f3..000000000 --- a/app/models/glitch/keyword_mute_helper.rb +++ /dev/null @@ -1,27 +0,0 @@ -require 'html2text' - -class Glitch::KeywordMuteHelper - attr_reader :text_matcher - attr_reader :tag_matcher - - def initialize(receiver_id) - @text_matcher = Glitch::KeywordMute.text_matcher_for(receiver_id) - @tag_matcher = Glitch::KeywordMute.tag_matcher_for(receiver_id) - end - - def matches?(status, scope) - matchers_match?(status, scope) || (status.reblog? && matchers_match?(status.reblog, scope)) - end - - private - - def matchers_match?(status, scope) - text_matcher.matches?(prepare_text(status.text), scope) || - text_matcher.matches?(prepare_text(status.spoiler_text), scope) || - tag_matcher.matches?(status.tags, scope) - end - - def prepare_text(text) - Html2Text.convert(text) - end -end diff --git a/config/navigation.rb b/config/navigation.rb index 6cb13612c..460027595 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -7,7 +7,6 @@ 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, highlights_on: %r{/settings/profile|/settings/migration} 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.security')]), 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 326cd1edf..9778d8b41 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,12 +77,6 @@ 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/20180707193142_migrate_filters.rb b/db/migrate/20180707193142_migrate_filters.rb new file mode 100644 index 000000000..455ce71ed --- /dev/null +++ b/db/migrate/20180707193142_migrate_filters.rb @@ -0,0 +1,54 @@ +class MigrateFilters < ActiveRecord::Migration[5.2] + class GlitchKeywordMute < ApplicationRecord + # Dummy class, as we removed Glitch::KeywordMute + belongs_to :account, required: true + validates_presence_of :keyword + end + + class CustomFilter < ApplicationRecord + # Dummy class, in case CustomFilter gets altered in the future + belongs_to :account + validates :phrase, :context, presence: true + + before_validation :clean_up_contexts + + private + + def clean_up_contexts + self.context = Array(context).map(&:strip).map(&:presence).compact + end + end + + disable_ddl_transaction! + + def up + GlitchKeywordMute.find_each do |filter| + filter.account.custom_filters.create!( + phrase: filter.keyword, + context: filter.apply_to_mentions ? %w(home public notifications) : %w(home public), + whole_word: filter.whole_word, + irreversible: true) + end + + drop_table :glitch_keyword_mutes + end + + def down + create_table "glitch_keyword_mutes" do |t| + t.references :account, null: false + t.string :keyword, null: false + t.boolean :whole_word, default: true, null: false + t.boolean :apply_to_mentions, default: true, null: false + t.timestamps + end + + add_foreign_key :glitch_keyword_mutes, :accounts, on_delete: :cascade + + CustomFilter.where(irreversible: true).find_each do |filter| + GlitchKeywordMute.where(account: filter.account).create!( + keyword: filter.phrase, + whole_word: filter.whole_word, + apply_to_mentions: filter.context.include?('notifications')) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 602bb6755..cf42e80e9 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_07_07_154237) do +ActiveRecord::Schema.define(version: 2018_07_07_193142) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -211,16 +211,6 @@ ActiveRecord::Schema.define(version: 2018_07_07_154237) 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.boolean "apply_to_mentions", default: true, null: false - t.index ["account_id"], name: "index_glitch_keyword_mutes_on_account_id" - end - create_table "identities", id: :serial, force: :cascade do |t| t.integer "user_id" t.string "provider", default: "", null: false @@ -603,7 +593,6 @@ ActiveRecord::Schema.define(version: 2018_07_07_154237) 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 "identities", "users", on_delete: :cascade add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade add_foreign_key "invites", "users", on_delete: :cascade diff --git a/spec/controllers/settings/keyword_mutes_controller_spec.rb b/spec/controllers/settings/keyword_mutes_controller_spec.rb deleted file mode 100644 index a8c37a072..000000000 --- a/spec/controllers/settings/keyword_mutes_controller_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 20d393320..000000000 --- a/spec/fabricators/glitch_keyword_mute_fabricator.rb +++ /dev/null @@ -1,2 +0,0 @@ -Fabricator('Glitch::KeywordMute') do -end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 8305530c5..a36ebad49 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -134,68 +134,6 @@ RSpec.describe FeedManager do 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 - - it 'returns true for a status with a tag that matches a muted keyword' do - Fabricate('Glitch::KeywordMute', account: alice, keyword: 'jorts') - status = Fabricate(:status, account: bob) - status.tags << Fabricate(:tag, name: 'jorts') - - expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true - end - - it 'returns true for a status with a tag that matches an octothorpe-prefixed muted keyword' do - Fabricate('Glitch::KeywordMute', account: alice, keyword: '#jorts') - status = Fabricate(:status, account: bob) - status.tags << Fabricate(:tag, name: 'jorts') - - expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true - end - - it 'returns false if the status is muted by a keyword mute that does not apply to mentions' do - Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take', apply_to_mentions: false) - status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob) - status.mentions.create!(account_id: alice.id) - - expect(FeedManager.instance.filter?(:home, status, alice.id)).to be false - end - context 'for irreversibly muted phrases' do it 'considers word boundaries when matching' do alice.custom_filters.create!(phrase: 'bob', context: %w(home), irreversible: true) @@ -247,20 +185,6 @@ 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 - - it 'returns false for a mention that contains a word muted by a keyword that does not apply to mentions' do - Fabricate('Glitch::KeywordMute', account: bob, keyword: 'take', apply_to_mentions: false) - status = Fabricate(:status, text: 'This is a hot take', account: alice) - bob.follow!(alice) - expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false - end end end diff --git a/spec/models/glitch/keyword_mute_helper_spec.rb b/spec/models/glitch/keyword_mute_helper_spec.rb deleted file mode 100644 index f9408d62d..000000000 --- a/spec/models/glitch/keyword_mute_helper_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -require 'rails_helper' - -RSpec.describe Glitch::KeywordMuteHelper do - describe '#matches?' do - Unscoped = Glitch::KeywordMute::Scopes::Unscoped - - let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) } - let(:helper) { Glitch::KeywordMuteHelper.new(alice) } - - it 'ignores names of HTML tags in status text' do - status = Fabricate(:status, text: 'uh example') - Glitch::KeywordMute.create!(account: alice, keyword: 'addr') - - expect(helper.matches?(status, Unscoped)).to be false - end - - it 'ignores properties of HTML tags in status text' do - status = Fabricate(:status, text: 'uh example') - Glitch::KeywordMute.create!(account: alice, keyword: 'href') - - expect(helper.matches?(status, Unscoped)).to be false - end - - it 'matches text inside HTML tags' do - status = Fabricate(:status, text: '

HEY THIS IS SOMETHING ANNOYING

') - Glitch::KeywordMute.create!(account: alice, keyword: 'annoying') - - expect(helper.matches?(status, Unscoped)).to be true - end - - it 'matches < in HTML-stripped text' do - status = Fabricate(:status, text: '

I <3 oats

') - Glitch::KeywordMute.create!(account: alice, keyword: '<3') - - expect(helper.matches?(status, Unscoped)).to be true - end - - it 'matches < in HTML text' do - status = Fabricate(:status, text: '

I <3 oats

') - Glitch::KeywordMute.create!(account: alice, keyword: '<3') - - expect(helper.matches?(status, Unscoped)).to be true - end - - it 'matches link hrefs in HTML text' do - status = Fabricate(:status, text: '

yep

') - Glitch::KeywordMute.create!(account: alice, keyword: 'milk') - - expect(helper.matches?(status, Unscoped)).to be true - end - end -end diff --git a/spec/models/glitch/keyword_mute_spec.rb b/spec/models/glitch/keyword_mute_spec.rb deleted file mode 100644 index 12c354738..000000000 --- a/spec/models/glitch/keyword_mute_spec.rb +++ /dev/null @@ -1,171 +0,0 @@ -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!) } - - Unscoped = Glitch::KeywordMute::Scopes::Unscoped - - describe '.text_matcher_for' do - let(:matcher) { Glitch::KeywordMute.text_matcher_for(alice.id) } - - describe 'with no mutes' do - before do - Glitch::KeywordMute.delete_all - end - - it 'does not match' do - expect(matcher.matches?('This is a hot take', Unscoped)).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.matches?('This is a hot take', Unscoped)).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.matches?('This is a hot take', Unscoped)).to be_falsy - end - - it 'considers word boundaries when matching' do - Glitch::KeywordMute.create!(account: alice, keyword: 'bob', whole_word: true) - - expect(matcher.matches?('bobcats', Unscoped)).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.matches?('This is a shiitake mushroom', Unscoped)).to be_truthy - end - - it 'matches keywords at the beginning of the text' do - Glitch::KeywordMute.create!(account: alice, keyword: 'take') - - expect(matcher.matches?('Take this', Unscoped)).to be_truthy - end - - it 'matches keywords at the end of the text' do - Glitch::KeywordMute.create!(account: alice, keyword: 'take') - - expect(matcher.matches?('This is a hot take', Unscoped)).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.matches?('This is a HOT take', Unscoped)).to be_truthy - end - - it 'matches if at least one non-whole-word keyword case-insensitively matches the text' do - Glitch::KeywordMute.create!(account: alice, keyword: 'hot', whole_word: false) - - expect(matcher.matches?('This is a HOTTY take', Unscoped)).to be_truthy - end - - it 'maintains case-insensitivity when combining keywords into a single matcher' do - Glitch::KeywordMute.create!(account: alice, keyword: 'hot') - Glitch::KeywordMute.create!(account: alice, keyword: 'cold') - - expect(matcher.matches?('This is a HOT take', Unscoped)).to be_truthy - end - - it 'matches keywords surrounded by non-alphanumeric ornamentation' do - Glitch::KeywordMute.create!(account: alice, keyword: 'hot') - - expect(matcher.matches?('(hot take)', Unscoped)).to be_truthy - end - - it 'escapes metacharacters in whole-word keywords' do - Glitch::KeywordMute.create!(account: alice, keyword: '(hot take)') - - expect(matcher.matches?('(hot take)', Unscoped)).to be_truthy - end - - it 'escapes metacharacters in non-whole-word keywords' do - Glitch::KeywordMute.create!(account: alice, keyword: '(-', whole_word: false) - - expect(matcher.matches?('bad (-)', Unscoped)).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.matches?('besuch der grosseltern', Unscoped)).to be_truthy - end - - it 'matches keywords that are composed of multiple words' do - Glitch::KeywordMute.create!(account: alice, keyword: 'a shiitake') - - expect(matcher.matches?('This is a shiitake', Unscoped)).to be_truthy - expect(matcher.matches?('This is shiitake', Unscoped)).to_not be_truthy - end - end - end - - describe '.tag_matcher_for' do - let(:matcher) { Glitch::KeywordMute.tag_matcher_for(alice.id) } - let(:status) { Fabricate(:status) } - - describe 'with no mutes' do - before do - Glitch::KeywordMute.delete_all - end - - it 'does not match' do - status.tags << Fabricate(:tag, name: 'xyzzy') - - expect(matcher.matches?(status.tags, Unscoped)).to be false - end - end - - describe 'with mutes' do - it 'does not match keywords set by a different account' do - status.tags << Fabricate(:tag, name: 'xyzzy') - Glitch::KeywordMute.create!(account: bob, keyword: 'take') - - expect(matcher.matches?(status.tags, Unscoped)).to be false - end - - it 'matches #xyzzy when given the mute "#xyzzy"' do - status.tags << Fabricate(:tag, name: 'xyzzy') - Glitch::KeywordMute.create!(account: alice, keyword: '#xyzzy') - - expect(matcher.matches?(status.tags, Unscoped)).to be true - end - - it 'matches #thingiverse when given the non-whole-word mute "#thing"' do - status.tags << Fabricate(:tag, name: 'thingiverse') - Glitch::KeywordMute.create!(account: alice, keyword: '#thing', whole_word: false) - - expect(matcher.matches?(status.tags, Unscoped)).to be true - end - - it 'matches #hashtag when given the mute "##hashtag""' do - status.tags << Fabricate(:tag, name: 'hashtag') - Glitch::KeywordMute.create!(account: alice, keyword: '##hashtag') - - expect(matcher.matches?(status.tags, Unscoped)).to be true - end - - it 'matches #oatmeal when given the non-whole-word mute "oat"' do - status.tags << Fabricate(:tag, name: 'oatmeal') - Glitch::KeywordMute.create!(account: alice, keyword: 'oat', whole_word: false) - - expect(matcher.matches?(status.tags, Unscoped)).to be true - end - - it 'does not match #oatmeal when given the mute "#oat"' do - status.tags << Fabricate(:tag, name: 'oatmeal') - Glitch::KeywordMute.create!(account: alice, keyword: 'oat') - - expect(matcher.matches?(status.tags, Unscoped)).to be false - end - end - end -end -- cgit