Merge pull request #222 from Retrospring/feature/muting
This commit is contained in:
commit
b7ce9cdbba
|
@ -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
|
|
@ -66,7 +66,9 @@ class Ajax::QuestionController < AjaxController
|
||||||
return
|
return
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
@response[:status] = :okay
|
@response[:status] = :okay
|
||||||
|
|
|
@ -224,4 +224,10 @@ class UserController < ApplicationController
|
||||||
@recovery_keys = TotpRecoveryCode.generate_for(current_user)
|
@recovery_keys = TotpRecoveryCode.generate_for(current_user)
|
||||||
render 'settings/security/recovery_keys'
|
render 'settings/security/recovery_keys'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# region Muting
|
||||||
|
def edit_mute
|
||||||
|
@rules = MuteRule.where(user: current_user)
|
||||||
|
end
|
||||||
|
# endregion
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,10 +2,12 @@ import start from 'retrospring/common';
|
||||||
import initAnswerbox from 'retrospring/features/answerbox/index';
|
import initAnswerbox from 'retrospring/features/answerbox/index';
|
||||||
import initInbox from 'retrospring/features/inbox/index';
|
import initInbox from 'retrospring/features/inbox/index';
|
||||||
import initUser from 'retrospring/features/user';
|
import initUser from 'retrospring/features/user';
|
||||||
|
import initSettings from 'retrospring/features/settings/index';
|
||||||
import initLists from 'retrospring/features/lists';
|
import initLists from 'retrospring/features/lists';
|
||||||
|
|
||||||
start();
|
start();
|
||||||
document.addEventListener('turbolinks:load', initAnswerbox);
|
document.addEventListener('turbolinks:load', initAnswerbox);
|
||||||
document.addEventListener('turbolinks:load', initInbox);
|
document.addEventListener('turbolinks:load', initInbox);
|
||||||
document.addEventListener('DOMContentLoaded', initUser);
|
document.addEventListener('DOMContentLoaded', initUser);
|
||||||
|
document.addEventListener('turbolinks:load', initSettings);
|
||||||
document.addEventListener('DOMContentLoaded', initLists);
|
document.addEventListener('DOMContentLoaded', initLists);
|
|
@ -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<HTMLDivElement>('.js-rules-list');
|
||||||
|
rulesList.querySelectorAll<HTMLDivElement>('.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')
|
||||||
|
}
|
|
@ -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<HTMLInputElement>('input').value = textEntry.value;
|
||||||
|
const newDeleteButton = newEntryFragment.querySelector<HTMLButtonElement>('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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -41,6 +41,7 @@ class User < ApplicationRecord
|
||||||
has_many :moderation_votes, dependent: :destroy
|
has_many :moderation_votes, dependent: :destroy
|
||||||
has_many :lists, dependent: :destroy
|
has_many :lists, dependent: :destroy
|
||||||
has_many :list_memberships, class_name: "ListMember", foreign_key: 'user_id', 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 :subscriptions, dependent: :destroy
|
||||||
has_many :totp_recovery_codes, dependent: :destroy
|
has_many :totp_recovery_codes, dependent: :destroy
|
||||||
|
|
|
@ -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
|
|
@ -5,6 +5,7 @@
|
||||||
= list_group_item t('views.settings.tabs.privacy'), edit_user_privacy_path
|
= 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.security'), edit_user_security_path
|
||||||
= list_group_item t('views.settings.tabs.sharing'), services_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 'Theme', edit_user_theme_path
|
||||||
= list_group_item 'Your Data', user_data_path
|
= list_group_item 'Your Data', user_data_path
|
||||||
= list_group_item 'Export', user_export_path
|
= list_group_item 'Export', user_export_path
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
= render 'settings/muted'
|
||||||
|
|
||||||
|
- provide(:title, generate_title('Muted words'))
|
||||||
|
- parent_layout 'user/settings'
|
|
@ -9,9 +9,12 @@ class QuestionWorker
|
||||||
# @param question_id [Integer] newly created question id
|
# @param question_id [Integer] newly created question id
|
||||||
def perform(user_id, question_id)
|
def perform(user_id, question_id)
|
||||||
user = User.find(user_id)
|
user = User.find(user_id)
|
||||||
|
question = Question.find(question_id)
|
||||||
|
|
||||||
user.followers.each do |f|
|
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
|
end
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
logger.info "failed to ask question: #{e.message}"
|
logger.info "failed to ask question: #{e.message}"
|
||||||
|
|
|
@ -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#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/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/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]
|
# resources :services, only: [:index, :destroy]
|
||||||
match '/settings/services', to: 'services#index', via: 'get', as: :services
|
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 '/list_membership', to: 'list#membership', via: :post, as: :list_membership
|
||||||
match '/subscribe', to: 'subscription#subscribe', via: :post, as: :subscribe_answer
|
match '/subscribe', to: 'subscription#subscribe', via: :post, as: :subscribe_answer
|
||||||
match '/unsubscribe', to: 'subscription#unsubscribe', via: :post, as: :unsubscribe_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
|
end
|
||||||
|
|
||||||
match '/discover', to: 'discover#index', via: :get, as: :discover
|
match '/discover', to: 'discover#index', via: :get, as: :discover
|
||||||
|
|
|
@ -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
|
|
@ -107,6 +107,14 @@ ActiveRecord::Schema.define(version: 2021_12_28_135426) do
|
||||||
t.index ["user_id"], name: "index_moderation_votes_on_user_id"
|
t.index ["user_id"], name: "index_moderation_votes_on_user_id"
|
||||||
end
|
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|
|
create_table "notifications", id: :serial, force: :cascade do |t|
|
||||||
t.string "target_type"
|
t.string "target_type"
|
||||||
t.bigint "target_id"
|
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"
|
t.index ["user_id"], name: "index_users_roles_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
add_foreign_key "mute_rules", "users"
|
||||||
add_foreign_key "profiles", "users"
|
add_foreign_key "profiles", "users"
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -26,5 +26,19 @@ describe QuestionWorker do
|
||||||
.to(5)
|
.to(5)
|
||||||
)
|
)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue