diff --git a/app/controllers/ajax/mute_rule_controller.rb b/app/controllers/ajax/mute_rule_controller.rb new file mode 100644 index 00000000..e0db82f4 --- /dev/null +++ b/app/controllers/ajax/mute_rule_controller.rb @@ -0,0 +1,67 @@ +class Ajax::MuteRuleController < AjaxController + def create + params.require :muted_phrase + + unless user_signed_in? + @response[:status] = :noauth + @response[:message] = I18n.t('messages.noauth') + return + end + + rule = MuteRule.create!(user: current_user, muted_phrase: params[:muted_phrase]) + @response[:status] = :okay + @response[:success] = true + @response[:message] = "Rule added successfully." + @response[:id] = rule.id + end + + def update + params.require :id + params.require :muted_phrase + + unless user_signed_in? + @response[:status] = :noauth + @response[:message] = I18n.t('messages.noauth') + return + end + + rule = MuteRule.find(params[:id]) + + if rule.user_id != current_user.id + @response[:status] = :nopriv + @response[:message] = "Can't edit other people's rules" + return + end + + rule.muted_phrase = params[:muted_phrase] + rule.save! + + @response[:status] = :okay + @response[:message] = "Rule updated successfully." + @response[:success] = true + end + + def destroy + params.require :id + + unless user_signed_in? + @response[:status] = :noauth + @response[:message] = I18n.t('messages.noauth') + return + end + + rule = MuteRule.find(params[:id]) + + if rule.user_id != current_user.id + @response[:status] = :nopriv + @response[:message] = "Can't edit other people's rules" + return + end + + rule.destroy! + + @response[:status] = :okay + @response[:message] = "Rule deleted successfully." + @response[:success] = true + end +end diff --git a/app/controllers/ajax/question_controller.rb b/app/controllers/ajax/question_controller.rb index c3c34737..9a140bef 100644 --- a/app/controllers/ajax/question_controller.rb +++ b/app/controllers/ajax/question_controller.rb @@ -66,7 +66,9 @@ class Ajax::QuestionController < AjaxController return end - Inbox.create!(user_id: u.id, question_id: question.id, new: true) + unless MuteRule.where(user: u).any? { |rule| rule.applies_to? question } + Inbox.create!(user_id: u.id, question_id: question.id, new: true) + end end @response[:status] = :okay diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb index edf88087..dcb5c57c 100644 --- a/app/controllers/user_controller.rb +++ b/app/controllers/user_controller.rb @@ -224,4 +224,10 @@ class UserController < ApplicationController @recovery_keys = TotpRecoveryCode.generate_for(current_user) render 'settings/security/recovery_keys' end + + # region Muting + def edit_mute + @rules = MuteRule.where(user: current_user) + end + # endregion end diff --git a/app/javascript/packs/application.ts b/app/javascript/packs/application.ts index 3fa1a7a6..41a9f0fb 100644 --- a/app/javascript/packs/application.ts +++ b/app/javascript/packs/application.ts @@ -2,10 +2,12 @@ import start from 'retrospring/common'; import initAnswerbox from 'retrospring/features/answerbox/index'; import initInbox from 'retrospring/features/inbox/index'; import initUser from 'retrospring/features/user'; +import initSettings from 'retrospring/features/settings/index'; import initLists from 'retrospring/features/lists'; start(); document.addEventListener('turbolinks:load', initAnswerbox); document.addEventListener('turbolinks:load', initInbox); document.addEventListener('DOMContentLoaded', initUser); +document.addEventListener('turbolinks:load', initSettings); document.addEventListener('DOMContentLoaded', initLists); \ No newline at end of file diff --git a/app/javascript/retrospring/features/settings/index.ts b/app/javascript/retrospring/features/settings/index.ts new file mode 100644 index 00000000..3aa64226 --- /dev/null +++ b/app/javascript/retrospring/features/settings/index.ts @@ -0,0 +1,18 @@ +import {createDeleteEvent, createSubmitEvent} from "retrospring/features/settings/mute"; + +export default (): void => { + const submit: HTMLButtonElement = document.getElementById('new-rule-submit') as HTMLButtonElement; + if (submit.classList.contains('js-initialized')) return; + + const rulesList = document.querySelector('.js-rules-list'); + rulesList.querySelectorAll('.form-group:not(.js-initalized)').forEach(entry => { + const button = entry.querySelector('button') + button.onclick = createDeleteEvent(entry, button) + }); + const textEntry: HTMLButtonElement = document.getElementById('new-rule-text') as HTMLButtonElement; + const template: HTMLTemplateElement = document.getElementById('rule-template') as HTMLTemplateElement; + + submit.form.onsubmit = createSubmitEvent(submit, rulesList, textEntry, template) + + submit.classList.add('js-initialized') +} \ No newline at end of file diff --git a/app/javascript/retrospring/features/settings/mute.ts b/app/javascript/retrospring/features/settings/mute.ts new file mode 100644 index 00000000..a402538d --- /dev/null +++ b/app/javascript/retrospring/features/settings/mute.ts @@ -0,0 +1,59 @@ +import Rails from '@rails/ujs'; + +export function createSubmitEvent( + submit: HTMLButtonElement, + rulesList: HTMLDivElement, + textEntry: HTMLButtonElement, + template: HTMLTemplateElement +): (event: Event) => void { + return (event) => { + event.preventDefault(); + submit.disabled = true; + + Rails.ajax({ + url: '/ajax/mute', + type: 'POST', + dataType: 'json', + data: new URLSearchParams({muted_phrase: textEntry.value}).toString(), + success: (data) => { + submit.disabled = false; + if (!data.success) return; + + const newEntryFragment = template.content.cloneNode(true) as Element; + newEntryFragment.querySelector('input').value = textEntry.value; + const newDeleteButton = newEntryFragment.querySelector('button') + newDeleteButton.dataset.id = String(data.id); + newDeleteButton.onclick = createDeleteEvent( + newEntryFragment.querySelector('.form-group'), + newDeleteButton + ) + + rulesList.appendChild(newEntryFragment) + + textEntry.value = '' + } + }); + }; +} + +export function createDeleteEvent( + entry: HTMLDivElement, + deleteButton: HTMLButtonElement +): () => void { + return () => { + deleteButton.disabled = true; + + Rails.ajax({ + url: '/ajax/mute/' + deleteButton.dataset.id, + type: 'DELETE', + dataType: 'json', + success: (data) => { + if (data.success) { + entry.parentElement.removeChild(entry) + } else { + deleteButton.disabled = false; + } + } + }) + } +} \ No newline at end of file diff --git a/app/models/mute_rule.rb b/app/models/mute_rule.rb new file mode 100644 index 00000000..b5b828b4 --- /dev/null +++ b/app/models/mute_rule.rb @@ -0,0 +1,7 @@ +class MuteRule < ApplicationRecord + belongs_to :user + + def applies_to?(post) + !!(post.content =~ /\b#{Regexp.escape(muted_phrase)}\b/i) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 5e86fc31..f3407967 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,6 +41,7 @@ class User < ApplicationRecord has_many :moderation_votes, dependent: :destroy has_many :lists, dependent: :destroy has_many :list_memberships, class_name: "ListMember", foreign_key: 'user_id', dependent: :destroy + has_many :mute_rules, dependent: :destroy has_many :subscriptions, dependent: :destroy has_many :totp_recovery_codes, dependent: :destroy diff --git a/app/views/settings/_muted.haml b/app/views/settings/_muted.haml new file mode 100644 index 00000000..2fea6e29 --- /dev/null +++ b/app/views/settings/_muted.haml @@ -0,0 +1,24 @@ +.card + .card-body + %h2 Muted words + %p Muting words (or longer phrases) will prevent questions containing those to appear in your inbox. + %p Note: Filtered questions are discarded completely from your inbox, and won't reappear if you remove a filter later on. + .js-rules-list + - @rules.each do |rule| + .form-group + .input-group + %input.form-control{ disabled: true, value: rule.muted_phrase } + .input-group-append + %button.btn.btn-danger{ type: 'button', data: { id: rule.id } } Remove + .form-group + %form + .input-group + %input.form-control#new-rule-text{ placeholder: 'Add a new muted word...' } + .input-group-append + %button.btn.btn-primary#new-rule-submit{ type: 'submit' } Add +%template#rule-template + .form-group + .input-group + %input.form-control{ disabled: true } + .input-group-append + %button.btn.btn-danger{ type: 'button' } Remove diff --git a/app/views/tabs/_settings.haml b/app/views/tabs/_settings.haml index d6210214..9195899a 100644 --- a/app/views/tabs/_settings.haml +++ b/app/views/tabs/_settings.haml @@ -5,6 +5,7 @@ = list_group_item t('views.settings.tabs.privacy'), edit_user_privacy_path = list_group_item t('views.settings.tabs.security'), edit_user_security_path = list_group_item t('views.settings.tabs.sharing'), services_path + = list_group_item 'Muted words', edit_user_mute_rules_path = list_group_item 'Theme', edit_user_theme_path = list_group_item 'Your Data', user_data_path = list_group_item 'Export', user_export_path diff --git a/app/views/user/edit_mute.haml b/app/views/user/edit_mute.haml new file mode 100644 index 00000000..aa4c6812 --- /dev/null +++ b/app/views/user/edit_mute.haml @@ -0,0 +1,4 @@ += render 'settings/muted' + +- provide(:title, generate_title('Muted words')) +- parent_layout 'user/settings' diff --git a/app/workers/question_worker.rb b/app/workers/question_worker.rb index b49ced7c..cd054e6b 100644 --- a/app/workers/question_worker.rb +++ b/app/workers/question_worker.rb @@ -9,9 +9,12 @@ class QuestionWorker # @param question_id [Integer] newly created question id def perform(user_id, question_id) user = User.find(user_id) + question = Question.find(question_id) user.followers.each do |f| - Inbox.create(user_id: f.id, question_id: question_id, new: true) + unless MuteRule.where(user: f).any? { |rule| rule.applies_to? question } + Inbox.create(user_id: f.id, question_id: question_id, new: true) + end end rescue StandardError => e logger.info "failed to ask question: #{e.message}" diff --git a/config/routes.rb b/config/routes.rb index 064e6ab0..6ff41bee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -73,6 +73,7 @@ Rails.application.routes.draw do match '/settings/security/2fa', to: 'user#update_2fa', via: :patch, as: :update_user_2fa match '/settings/security/2fa', to: 'user#destroy_2fa', via: :delete, as: :destroy_user_2fa match '/settings/security/recovery', to: 'user#reset_user_recovery_codes', via: :delete, as: :reset_user_recovery_codes + match '/settings/muted', to: 'user#edit_mute', via: :get, as: :edit_user_mute_rules # resources :services, only: [:index, :destroy] match '/settings/services', to: 'services#index', via: 'get', as: :services @@ -114,6 +115,9 @@ Rails.application.routes.draw do match '/list_membership', to: 'list#membership', via: :post, as: :list_membership match '/subscribe', to: 'subscription#subscribe', via: :post, as: :subscribe_answer match '/unsubscribe', to: 'subscription#unsubscribe', via: :post, as: :unsubscribe_answer + match '/mute', to: 'mute_rule#create', via: :post, as: :create_mute_rule + match '/mute/:id', to: 'mute_rule#update', via: :post, as: :update_mute_rule + match '/mute/:id', to: 'mute_rule#destroy', via: :delete, as: :delete_mute_rule end match '/discover', to: 'discover#index', via: :get, as: :discover diff --git a/db/migrate/20211222165159_create_mute_rules.rb b/db/migrate/20211222165159_create_mute_rules.rb new file mode 100644 index 00000000..0e548bc5 --- /dev/null +++ b/db/migrate/20211222165159_create_mute_rules.rb @@ -0,0 +1,12 @@ +class CreateMuteRules < ActiveRecord::Migration[5.2] + def change + create_table :mute_rules do |t| + t.references :user, foreign_key: true + t.string :muted_phrase + + t.timestamps + end + + change_column(:mute_rules, :id, :bigint, default: -> { "gen_timestamp_id('mute_rules'::text)" }) + end +end diff --git a/db/schema.rb b/db/schema.rb index 017794d2..a1294985 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -107,6 +107,14 @@ ActiveRecord::Schema.define(version: 2021_12_28_135426) do t.index ["user_id"], name: "index_moderation_votes_on_user_id" end + create_table "mute_rules", id: :bigint, default: -> { "gen_timestamp_id('mute_rules'::text)" }, force: :cascade do |t| + t.bigint "user_id" + t.string "muted_phrase" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_mute_rules_on_user_id" + end + create_table "notifications", id: :serial, force: :cascade do |t| t.string "target_type" t.bigint "target_id" @@ -305,5 +313,6 @@ ActiveRecord::Schema.define(version: 2021_12_28_135426) do t.index ["user_id"], name: "index_users_roles_on_user_id" end + add_foreign_key "mute_rules", "users" add_foreign_key "profiles", "users" end diff --git a/spec/controllers/ajax/mute_rule_controller_spec.rb b/spec/controllers/ajax/mute_rule_controller_spec.rb new file mode 100644 index 00000000..603a6f97 --- /dev/null +++ b/spec/controllers/ajax/mute_rule_controller_spec.rb @@ -0,0 +1,89 @@ +require 'rails_helper' + +describe Ajax::MuteRuleController, :ajax_controller, type: :controller do + + describe "#create" do + subject { post(:create, params: params) } + + context "when user is signed in" do + before(:each) { sign_in(user) } + + let(:params) { { muted_phrase: 'test' } } + let(:expected_response) do + { + "success" => true, + "status" => "okay", + "id" => MuteRule.last.id, + "message" => "Rule added successfully.", + } + end + + it "creates a mute rule" do + expect { subject }.to change { MuteRule.count }.by(1) + expect(response).to have_http_status(:success) + + rule = MuteRule.first + expect(rule.user_id).to eq(user.id) + expect(rule.muted_phrase).to eq('test') + end + + include_examples "returns the expected response" + end + end + + describe "#update" do + subject { post(:update, params: params) } + + context "when user is signed in" do + before(:each) { sign_in(user) } + + let(:rule) { MuteRule.create(user: user, muted_phrase: 'test') } + let(:params) { { id: rule.id, muted_phrase: 'dogs' } } + let(:expected_response) do + { + "success" => true, + "status" => "okay", + "message" => "Rule updated successfully." + } + end + + it "updates a mute rule" do + subject + expect(response).to have_http_status(:success) + + expect(rule.reload.muted_phrase).to eq('dogs') + end + + include_examples "returns the expected response" + end + end + + describe "#destroy" do + subject { delete(:destroy, params: params) } + + context "when user is signed in" do + before(:each) { sign_in(user) } + + let(:rule) { MuteRule.create(user: user, muted_phrase: 'test') } + let(:params) { { id: rule.id } } + let(:expected_response) do + { + "success" => true, + "status" => "okay", + "message" => "Rule deleted successfully." + } + end + + it "deletes a mute rule" do + subject + expect(response).to have_http_status(:success) + + expect(MuteRule.exists?(rule.id)).to eq(false) + + end + + include_examples "returns the expected response" + end + end + +end diff --git a/spec/models/mute_rule_spec.rb b/spec/models/mute_rule_spec.rb new file mode 100644 index 00000000..5d47fac6 --- /dev/null +++ b/spec/models/mute_rule_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +RSpec.describe MuteRule, type: :model do + describe "#applies_to?" do + let(:user) { FactoryBot.create(:user) } + let(:rule) { MuteRule.create(user: user, muted_phrase: "trial") } + let(:question) { Question.create(user: user, content: "Did you know that the critically acclaimed MMORPG Final Fantasy XIV has a free trial, and includes the entirety of A Realm Reborn AND the award-winning Heavensward expansion up to level 60 with no restrictions on playtime?") } + + it "only returns true for questions matching a certain phrase" do + expect(rule.applies_to?(question)).to be(true) + end + end +end diff --git a/spec/workers/question_worker_spec.rb b/spec/workers/question_worker_spec.rb index 832276ac..dc069f82 100644 --- a/spec/workers/question_worker_spec.rb +++ b/spec/workers/question_worker_spec.rb @@ -26,5 +26,19 @@ describe QuestionWorker do .to(5) ) end + + it "respects mute rules" do + question.content = 'Some spicy question text' + question.save + + MuteRule.create(user_id: user.followers.first.id, muted_phrase: 'spicy') + + expect { subject } + .to( + change { Inbox.where(user_id: user.followers.ids, question_id: question_id, new: true).count } + .from(0) + .to(4) + ) + end end end