]> git.openstreetmap.org Git - rails.git/commitdiff
Merge remote-tracking branch 'upstream/pull/4428'
authorTom Hughes <tom@compton.nu>
Thu, 21 Dec 2023 11:01:48 +0000 (11:01 +0000)
committerTom Hughes <tom@compton.nu>
Thu, 21 Dec 2023 11:01:48 +0000 (11:01 +0000)
32 files changed:
.rubocop_todo.yml
app/abilities/ability.rb
app/controllers/messages_controller.rb
app/controllers/user_mutes_controller.rb [new file with mode: 0644]
app/models/message.rb
app/models/user.rb
app/models/user_mute.rb [new file with mode: 0644]
app/views/application/_settings_menu.html.erb
app/views/messages/_heading.html.erb [new file with mode: 0644]
app/views/messages/_message_summary.html.erb
app/views/messages/_messages_table.html.erb [new file with mode: 0644]
app/views/messages/inbox.html.erb
app/views/messages/muted.html.erb [new file with mode: 0644]
app/views/messages/outbox.html.erb
app/views/messages/show.html.erb
app/views/user_mutes/index.html.erb [new file with mode: 0644]
app/views/users/show.html.erb
config/application.rb
config/locales/en.yml
config/routes.rb
config/settings/test.yml
db/migrate/20231010201451_create_user_mutes.rb [new file with mode: 0644]
db/migrate/20231010203028_add_muted_flag_to_messages.rb [new file with mode: 0644]
db/structure.sql
script/deliver-message
test/controllers/user_mutes_controller_test.rb [new file with mode: 0644]
test/factories/messages.rb
test/factories/user_mute.rb [new file with mode: 0644]
test/integration/user_creation_test.rb
test/models/message_test.rb
test/models/user_mute_test.rb [new file with mode: 0644]
test/system/user_muting_test.rb [new file with mode: 0644]

index bd2f92309886c83b83e4dfdaecd5cc8153f23cc7..e6772b8a4fc6d764a35eca646cd41609e406abb1 100644 (file)
@@ -61,7 +61,7 @@ Metrics/BlockNesting:
 # Offense count: 26
 # Configuration parameters: CountComments, CountAsOne.
 Metrics/ClassLength:
-  Max: 297
+  Max: 299
 
 # Offense count: 59
 # Configuration parameters: AllowedMethods, AllowedPatterns.
index f9348f68e707ae9897bd0a2e2690e7e4187573d0..b9da5d08ac596166124884cc2864eb31155be342 100644 (file)
@@ -47,13 +47,14 @@ class Ability
         can [:show], :dashboard
         can [:new, :create, :edit, :update, :comment, :subscribe, :unsubscribe], DiaryEntry
         can [:make_friend, :remove_friend], Friendship
-        can [:new, :create, :reply, :show, :inbox, :outbox, :mark, :destroy], Message
+        can [:new, :create, :reply, :show, :inbox, :outbox, :muted, :mark, :unmute, :destroy], Message
         can [:close, :reopen], Note
         can [:show, :edit, :update], :preference
         can [:edit, :update], :profile
         can [:new, :create], Report
         can [:mine, :new, :create, :edit, :update, :destroy], Trace
         can [:account, :go_public], User
+        can [:index, :create, :destroy], UserMute
 
         if user.moderator?
           can [:hide, :unhide, :hidecomment, :unhidecomment], DiaryEntry
index adb53b43ba3842d8cd0b3faf5aaa898abde4b213..2ca86fc028dca2dbe9af4c0a8ec14b71665c3ca9 100644 (file)
@@ -47,7 +47,7 @@ class MessagesController < ApplicationController
       render :action => "new"
     elsif @message.save
       flash[:notice] = t ".message_sent"
-      UserMailer.message_notification(@message).deliver_later
+      UserMailer.message_notification(@message).deliver_later if @message.notify_recipient?
       redirect_to :action => :inbox
     else
       @title = t "messages.new.title"
@@ -107,6 +107,13 @@ class MessagesController < ApplicationController
     @title = t ".title"
   end
 
+  # Display the list of muted messages received by the user.
+  def muted
+    @title = t ".title"
+
+    redirect_to inbox_messages_path if current_user.muted_messages.none?
+  end
+
   # Set the message as being read or unread.
   def mark
     @message = Message.where(:recipient => current_user).or(Message.where(:sender => current_user)).find(params[:message_id])
@@ -127,6 +134,23 @@ class MessagesController < ApplicationController
     render :action => "no_such_message", :status => :not_found
   end
 
+  # Moves message into Inbox by unsetting the muted-flag
+  def unmute
+    message = current_user.muted_messages.find(params[:message_id])
+
+    if message.unmute
+      flash[:notice] = t(".notice")
+    else
+      flash[:error] = t(".error")
+    end
+
+    if current_user.muted_messages.none?
+      redirect_to inbox_messages_path
+    else
+      redirect_to muted_messages_path
+    end
+  end
+
   private
 
   ##
diff --git a/app/controllers/user_mutes_controller.rb b/app/controllers/user_mutes_controller.rb
new file mode 100644 (file)
index 0000000..2068ab6
--- /dev/null
@@ -0,0 +1,45 @@
+class UserMutesController < ApplicationController
+  include UserMethods
+
+  layout "site"
+
+  before_action :authorize_web
+  before_action :set_locale
+
+  authorize_resource
+
+  before_action :lookup_user, :only => [:create, :destroy]
+  before_action :check_database_readable
+  before_action :check_database_writable, :only => [:create, :destroy]
+
+  def index
+    @muted_users = current_user.muted_users
+    @title = t ".title"
+
+    redirect_to edit_account_path unless @muted_users.any?
+  end
+
+  def create
+    user_mute = current_user.mutes.build(:subject => @user)
+
+    if user_mute.save
+      flash[:notice] = t(".notice", :name => user_mute.subject.display_name)
+    else
+      flash[:error] = t(".error", :name => user_mute.subject.display_name, :full_message => user_mute.errors.full_messages.to_sentence.humanize)
+    end
+
+    redirect_back_or_to user_mutes_path(current_user)
+  end
+
+  def destroy
+    user_mute = current_user.mutes.find_by!(:subject => @user)
+
+    if user_mute.destroy
+      flash[:notice] = t(".notice", :name => user_mute.subject.display_name)
+    else
+      flash[:error] = t(".error")
+    end
+
+    redirect_back_or_to user_mutes_path(current_user)
+  end
+end
index 7c12769d3a3e39f081caeb16dd0417a858a0ddc2..665e2d721376b897738691c73cf4a62c0bc3bd4b 100644 (file)
@@ -12,6 +12,7 @@
 #  to_user_visible   :boolean          default(TRUE), not null
 #  from_user_visible :boolean          default(TRUE), not null
 #  body_format       :enum             default("markdown"), not null
+#  muted             :boolean          default(FALSE), not null
 #
 # Indexes
 #
@@ -32,6 +33,10 @@ class Message < ApplicationRecord
   validates :body, :sent_on, :presence => true
   validates :title, :body, :characters => true
 
+  scope :muted, -> { where(:muted => true) }
+
+  before_create :set_muted
+
   def self.from_mail(mail, from, to)
     if mail.multipart?
       if mail.text_part
@@ -65,4 +70,18 @@ class Message < ApplicationRecord
     sha256 << id.to_s
     Base64.urlsafe_encode64(sha256.digest)[0, 8]
   end
+
+  def notify_recipient?
+    !muted?
+  end
+
+  def unmute
+    update(:muted => false)
+  end
+
+  private
+
+  def set_muted
+    self.muted ||= UserMute.active?(:owner => recipient, :subject => sender)
+  end
 end
index 1942a25cc237a784394b91f9ba64c844f36e3309..5790d81e51d1be60d561eaa934aa863e2aad913b 100644 (file)
@@ -51,9 +51,10 @@ class User < ApplicationRecord
   has_many :diary_comments, -> { order(:created_at => :desc) }, :inverse_of => :user
   has_many :diary_entry_subscriptions, :class_name => "DiaryEntrySubscription"
   has_many :diary_subscriptions, :through => :diary_entry_subscriptions, :source => :diary_entry
-  has_many :messages, -> { where(:to_user_visible => true).order(:sent_on => :desc).preload(:sender, :recipient) }, :foreign_key => :to_user_id
-  has_many :new_messages, -> { where(:to_user_visible => true, :message_read => false).order(:sent_on => :desc) }, :class_name => "Message", :foreign_key => :to_user_id
+  has_many :messages, -> { where(:to_user_visible => true, :muted => false).order(:sent_on => :desc).preload(:sender, :recipient) }, :foreign_key => :to_user_id
+  has_many :new_messages, -> { where(:to_user_visible => true, :muted => false, :message_read => false).order(:sent_on => :desc) }, :class_name => "Message", :foreign_key => :to_user_id
   has_many :sent_messages, -> { where(:from_user_visible => true).order(:sent_on => :desc).preload(:sender, :recipient) }, :class_name => "Message", :foreign_key => :from_user_id
+  has_many :muted_messages, -> { where(:to_user_visible => true, :muted => true).order(:sent_on => :desc).preload(:sender, :recipient) }, :class_name => "Message", :foreign_key => :to_user_id
   has_many :friendships, -> { joins(:befriendee).where(:users => { :status => %w[active confirmed] }) }
   has_many :friends, :through => :friendships, :source => :befriendee
   has_many :tokens, :class_name => "UserToken", :dependent => :destroy
@@ -75,6 +76,9 @@ class User < ApplicationRecord
   has_many :blocks_created, :class_name => "UserBlock", :foreign_key => :creator_id, :inverse_of => :creator
   has_many :blocks_revoked, :class_name => "UserBlock", :foreign_key => :revoker_id, :inverse_of => :revoker
 
+  has_many :mutes, -> { order(:created_at => :desc) }, :class_name => "UserMute", :foreign_key => :owner_id, :inverse_of => :owner
+  has_many :muted_users, :through => :mutes, :source => :subject
+
   has_many :roles, :class_name => "UserRole"
 
   has_many :issues, :class_name => "Issue", :foreign_key => :reported_user_id, :inverse_of => :reported_user
diff --git a/app/models/user_mute.rb b/app/models/user_mute.rb
new file mode 100644 (file)
index 0000000..9bee39b
--- /dev/null
@@ -0,0 +1,34 @@
+# == Schema Information
+#
+# Table name: user_mutes
+#
+#  id         :bigint(8)        not null, primary key
+#  owner_id   :bigint(8)        not null
+#  subject_id :bigint(8)        not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+# Indexes
+#
+#  index_user_mutes_on_owner_id_and_subject_id  (owner_id,subject_id) UNIQUE
+#
+# Foreign Keys
+#
+#  fk_rails_...  (owner_id => users.id)
+#  fk_rails_...  (subject_id => users.id)
+#
+class UserMute < ApplicationRecord
+  belongs_to :owner, :class_name => "User"
+  belongs_to :subject, :class_name => "User"
+
+  validates :subject, :uniqueness => { :scope => :owner_id, :message => :is_already_muted }
+
+  def self.active?(owner:, subject:)
+    !subject.administrator? &&
+      !subject.moderator? &&
+      exists?(
+        :owner => owner,
+        :subject => subject
+      )
+  end
+end
index 9ce9755a2c5f3e452e016f2ce0fb6e6551250c40..8477a11a05af10c542e3b9dd17433be9904d3f64 100644 (file)
     <li class="nav-item">
       <%= link_to t(".oauth2_authorizations"), oauth_authorized_applications_path, :class => "nav-link #{'active' if controller_name == 'oauth2_authorized_applications'}" %>
     </li>
+    <% if current_user.muted_users.any? %>
+      <li class="nav-item">
+        <%= link_to t(".muted_users"), user_mutes_path, :class => "nav-link #{'active' if controller_name == 'user_mutes'}" %>
+      </li>
+    <% end %>
   </ul>
 <% end %>
diff --git a/app/views/messages/_heading.html.erb b/app/views/messages/_heading.html.erb
new file mode 100644 (file)
index 0000000..90995ed
--- /dev/null
@@ -0,0 +1,18 @@
+<% content_for :heading_class, "pb-0" %>
+
+<% content_for :heading do %>
+  <h1><%= t("users.show.my messages") %></h1>
+  <ul class="nav nav-tabs">
+    <% { ".my_inbox" => inbox_messages_path, ".my_outbox" => outbox_messages_path, ".muted_messages" => muted_messages_path }.each do |i18n_key, path| %>
+    <% next if path == muted_messages_path && current_user.muted_messages.none? %>
+
+    <li class="nav-item">
+      <% if path == active_link_path %>
+        <a class="nav-link active"><%= t(i18n_key) %></a>
+      <% else %>
+        <%= link_to t(i18n_key), path, :class => "nav-link" %>
+      <% end %>
+    </li>
+    <% end %>
+  </ul>
+<% end %>
index b2a1bc26868e82de84ee3cac44a0357c313a36b2..4a552e83e05479a7d6df58d9cd0f23311c3742ef 100644 (file)
@@ -6,5 +6,8 @@
     <%= button_to t(".unread_button"), message_mark_path(message_summary, :mark => "unread"), :remote => true, :class => "btn btn-sm btn-primary", :form => { :class => "inbox-mark-unread", :hidden => !message_summary.message_read? } %>
     <%= button_to t(".read_button"), message_mark_path(message_summary, :mark => "read"), :remote => true, :class => "btn btn-sm btn-primary", :form => { :class => "inbox-mark-read", :hidden => message_summary.message_read? } %>
     <%= button_to t(".destroy_button"), message_path(message_summary, :referer => request.fullpath), :method => :delete, :remote => true, :class => "btn btn-sm btn-danger", :form_class => "inbox-destroy" %>
+    <% if message_summary.muted? %>
+      <%= button_to t(".unmute_button"), message_unmute_path(message_summary), :method => :patch, :class => "btn btn-sm btn-secondary" %>
+    <% end %>
   </td>
 </tr>
diff --git a/app/views/messages/_messages_table.html.erb b/app/views/messages/_messages_table.html.erb
new file mode 100644 (file)
index 0000000..2e33962
--- /dev/null
@@ -0,0 +1,13 @@
+<table class="table table-sm align-middle">
+  <thead>
+    <tr>
+      <% columns.each do |column| %>
+      <th><%= t ".#{column}" %></th>
+      <% end %>
+      <th class="d-flex justify-content-end"><%= t ".actions" %></th>
+    </tr>
+  </thead>
+  <tbody>
+    <%= render :partial => inner_partial, :collection => messages %>
+  </tbody>
+</table>
index 54089c34a65ba6cea80c625b0ed7b362b0e7cdcd..db807d2dfc732fd744335abf2234eb29ccd13dff 100644 (file)
@@ -2,35 +2,12 @@
   <%= javascript_include_tag "messages" %>
 <% end %>
 
-<% content_for :heading_class, "pb-0" %>
+<%= render :partial => "heading", :locals => { :active_link_path => inbox_messages_path } %>
 
-<% content_for :heading do %>
-  <h1><%= t("users.show.my messages") %></h1>
-  <ul class="nav nav-tabs">
-    <li class="nav-item">
-      <a class="nav-link active"><%= t ".my_inbox" %></a>
-    </li>
-    <li class="nav-item">
-      <%= link_to t(".my_outbox"), outbox_messages_path, :class => "nav-link" %>
-    </li>
-  </ul>
-<% end %>
-
-  <h4><%= render :partial => "message_count" %></h4>
+<h4><%= render :partial => "message_count" %></h4>
 
 <% if current_user.messages.size > 0 %>
-  <table class="table table-sm align-middle">
-    <thead>
-      <tr>
-        <th><%= t ".from" %></th>
-        <th><%= t ".subject" %></th>
-        <th><%= t ".date" %></th>
-      </tr>
-    </thead>
-    <tbody>
-        <%= render :partial => "message_summary", :collection => current_user.messages %>
-    </tbody>
-  </table>
+  <%= render :partial => "messages_table", :locals => { :columns => %w[from subject date], :messages => current_user.messages, :inner_partial => "message_summary" } %>
 <% else %>
   <div><%= t(".no_messages_yet_html", :people_mapping_nearby_link => link_to(t(".people_mapping_nearby"), user_path(current_user))) %></div>
 <% end %>
diff --git a/app/views/messages/muted.html.erb b/app/views/messages/muted.html.erb
new file mode 100644 (file)
index 0000000..40c74e9
--- /dev/null
@@ -0,0 +1,9 @@
+<% content_for :head do %>
+  <%= javascript_include_tag "messages" %>
+<% end %>
+
+<%= render :partial => "heading", :locals => { :active_link_path => muted_messages_path } %>
+
+<h4><%= t ".messages", :count => current_user.muted_messages.size %></h4>
+
+<%= render :partial => "messages_table", :locals => { :columns => %w[from subject date], :messages => current_user.muted_messages, :inner_partial => "message_summary" } %>
index e246f9292ebb8afaf0161e306b55fb812176f379..ae8a899c484850b7ab6ec52cc3425dc57a38be9e 100644 (file)
@@ -2,36 +2,12 @@
   <%= javascript_include_tag "messages" %>
 <% end %>
 
-<% content_for :heading_class, "pb-0" %>
-
-<% content_for :heading do %>
-  <h1><%= t("users.show.my messages") %></h1>
-
-  <ul class="nav nav-tabs">
-    <li class="nav-item">
-      <%= link_to t(".my_inbox"), inbox_messages_path, :class => "nav-link" %>
-    </li>
-    <li class="nav-item">
-      <a class="nav-link active"><%= t ".my_outbox" %></a>
-    </li>
-  </ul>
-<% end %>
+<%= render :partial => "heading", :locals => { :active_link_path => outbox_messages_path } %>
 
 <h4><%= t ".messages", :count => current_user.sent_messages.size %></h4>
 
 <% if current_user.sent_messages.size > 0 %>
-  <table class="table table-sm align-middle">
-    <thead>
-      <tr>
-        <th><%= t ".to" %></th>
-        <th><%= t ".subject" %></th>
-        <th><%= t ".date" %></th>
-      </tr>
-    </thead>
-    <tbody>
-      <%= render :partial => "sent_message_summary", :collection => current_user.sent_messages %>
-    </tbody>
-  </table>
+  <%= render :partial => "messages_table", :locals => { :columns => %w[to subject date], :messages => current_user.sent_messages, :inner_partial => "sent_message_summary" } %>
 <% else %>
   <div class="messages"><%= t(".no_sent_messages_html", :people_mapping_nearby_link => link_to(t(".people_mapping_nearby"), user_path(current_user))) %></div>
 <% end %>
index 4761aac60d95d4cd5de4dfa4f1481f85248e5a7f..f0742280f76e410cc5ebcdc8ab9c9830d7b5e32b 100644 (file)
@@ -22,9 +22,8 @@
     <%= link_to t(".reply_button"), message_reply_path(@message), :class => "btn btn-primary" %>
     <%= link_to t(".unread_button"), message_mark_path(@message, :mark => "unread"), :method => "post", :class => "btn btn-primary" %>
     <%= link_to t(".destroy_button"), message_path(@message), :method => "delete", :class => "btn btn-danger" %>
-    <%= link_to t(".back"), inbox_messages_path, :class => "btn btn-link" %>
   <% else %>
     <%= link_to t(".destroy_button"), message_path(@message), :method => "delete", :class => "btn btn-danger" %>
-    <%= link_to t(".back"), outbox_messages_path, :class => "btn btn-link" %>
   <% end %>
+  <%= link_to t(".back"), :back, :class => "btn btn-link" %>
 </div>
diff --git a/app/views/user_mutes/index.html.erb b/app/views/user_mutes/index.html.erb
new file mode 100644 (file)
index 0000000..cf9e7ed
--- /dev/null
@@ -0,0 +1,38 @@
+<% content_for :heading do %>
+  <h1><%= t ".my_muted_users" %></h1>
+<% end %>
+
+<%= render :partial => "settings_menu" %>
+
+<h4>
+  <%= t ".you_have_muted_n_users", :count => @muted_users.size %>
+</h4>
+<p>
+  <%= t ".user_mute_explainer" %>
+  <em><%= t ".user_mute_admins_and_moderators" %></em>
+</p>
+
+<% if @muted_users.any? %>
+  <table class="table">
+    <thead>
+      <tr>
+        <th><%= t ".table.thead.muted_user" %></th>
+        <th class="d-flex justify-content-end"><%= t ".table.thead.actions" %></th>
+      </tr>
+    </thead>
+    <tbody>
+      <% @muted_users.each do |user| %>
+        <tr>
+          <td>
+            <%= user_thumbnail_tiny user %>
+            <%= link_to user.display_name, user_path(user) %>
+          </td>
+          <td class="text-nowrap text-end">
+            <%= link_to t(".table.tbody.unmute"), user_mute_path(user), :method => :delete, :class => "btn btn-sm btn-primary" %>
+            <%= link_to t(".table.tbody.send_message"), new_message_path(user), :class => "btn btn-sm btn-secondary" %>
+          </td>
+        </tr>
+      <% end %>
+    </tbody>
+  </table>
+<% end %>
index 81fda926b7a081b333add6d07f6d3aaa87989668..0c803ebb03cf3492919d24210c0c6ed4716502c2 100644 (file)
                 <%= report_link(t(".report"), @user) %>
               </li>
             <% end %>
+
+            <% if current_user and UserMute.exists?(owner: current_user, subject: @user) %>
+              <li>
+              <%= link_to t(".destroy_mute"), user_mute_path(@user), :method => :delete %>
+              </li>
+            <% elsif current_user %>
+              <li>
+              <%= link_to t(".create_mute"), user_mute_path(@user), :method => :post, :title => t("user_mutes.index.user_mute_explainer") %>
+              </li>
+            <% end %>
           </ul>
         </nav>
       <% end %>
index e568c854052cb177b6f3cf69537ed47df17bbb6a..4517e8adfb611d8d073041274321212ff95bf6e1 100644 (file)
@@ -35,6 +35,8 @@ module OpenStreetMap
     # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
     # the I18n.default_locale when a translation cannot be found).
     config.i18n.fallbacks = true
+    # Enables custom error message formats
+    config.active_model.i18n_customize_full_message = true
 
     # Use logstash for logging if required
     if Settings.key?(:logstash_path)
index 18beafa1355a0062daaf5cf53f6d3008f409e82a..7ff2bfd0676ed460f1b4a66afc209529e34da16c 100644 (file)
@@ -40,6 +40,12 @@ en:
       messages:
         invalid_email_address: does not appear to be a valid e-mail address
         email_address_not_routable: is not routable
+      models:
+        user_mute:
+          attributes:
+            subject:
+              format: "%{message}"
+          is_already_muted: "is already muted"
     # Translates all the model names, which is used in error handling on the website
     models:
       acl: "Access Control List"
@@ -1673,8 +1679,6 @@ en:
   messages:
     inbox:
       title: "Inbox"
-      my_inbox: "My Inbox"
-      my_outbox: "My Outbox"
       messages: "You have %{new_messages} and %{old_messages}"
       new_messages:
         one: "%{count} new message"
@@ -1682,16 +1686,20 @@ en:
       old_messages:
         one: "%{count} old message"
         other: "%{count} old messages"
+      no_messages_yet_html: "You have no messages yet. Why not get in touch with some of the %{people_mapping_nearby_link}?"
+      people_mapping_nearby: "people mapping nearby"
+    messages_table:
       from: "From"
+      to: "To"
       subject: "Subject"
       date: "Date"
-      no_messages_yet_html: "You have no messages yet. Why not get in touch with some of the %{people_mapping_nearby_link}?"
-      people_mapping_nearby: "people mapping nearby"
+      actions: "Actions"
     message_summary:
       unread_button: "Mark as unread"
       read_button: "Mark as read"
       reply_button: "Reply"
       destroy_button: "Delete"
+      unmute_button: "Move to Inbox"
     new:
       title: "Send message"
       send_message_to_html: "Send a new message to %{name}"
@@ -1705,16 +1713,17 @@ en:
       body: "Sorry there is no message with that id."
     outbox:
       title: "Outbox"
-      my_inbox: "My Inbox"
-      my_outbox: "My Outbox"
+      actions: "Actions"
       messages:
         one: "You have %{count} sent message"
         other: "You have %{count} sent messages"
-      to: "To"
-      subject: "Subject"
-      date: "Date"
       no_sent_messages_html: "You have no sent messages yet. Why not get in touch with some of the %{people_mapping_nearby_link}?"
       people_mapping_nearby: "people mapping nearby"
+    muted:
+      title: "Muted Messages"
+      messages:
+        one: "%{count} muted message"
+        other: "You have %{count} muted messages"
     reply:
       wrong_user: "You are logged in as `%{user}' but the message you have asked to reply to was not sent to that user. Please login as the correct user in order to reply."
     show:
@@ -1726,9 +1735,16 @@ en:
       wrong_user: "You are logged in as `%{user}' but the message you have asked to read was not sent by or to that user. Please login as the correct user in order to read it."
     sent_message_summary:
       destroy_button: "Delete"
+    heading: 
+      my_inbox: "My Inbox"
+      my_outbox: "My Outbox"
+      muted_messages: "Muted messages"
     mark:
       as_read: "Message marked as read"
       as_unread: "Message marked as unread"
+    unmute:
+      notice: "Message has been moved to Inbox"
+      error: "The message could not be moved to the Inbox."
     destroy:
       destroyed: "Message deleted"
   passwords:
@@ -2558,6 +2574,7 @@ en:
       oauth1_settings: OAuth 1 settings
       oauth2_applications: OAuth 2 applications
       oauth2_authorizations: OAuth 2 authorizations
+      muted_users: Muted Users
   oauth:
     authorize:
       title: "Authorize access to your account"
@@ -2745,6 +2762,8 @@ en:
       my_dashboard: My Dashboard
       blocks on me: Blocks on Me
       blocks by me: Blocks by Me
+      create_mute: Mute this User
+      destroy_mute: Unmute this User
       edit_profile: Edit Profile
       send message: Send Message
       diary: Diary
@@ -2935,6 +2954,29 @@ en:
       showing_page: "Page %{page}"
       next: "Next Â»"
       previous: "« Previous"
+  user_mutes:
+    index:
+      title: "Muted Users"
+      my_muted_users: "My muted users"
+      you_have_muted_n_users:
+        one: "You have muted %{count} User"
+        other: "You have muted %{count} users"
+      user_mute_explainer: "Messages of muted users are moved into a separate Inbox and you won't receive email notifications."
+      user_mute_admins_and_moderators: "You can mute Admins and Moderators but their messages will not be muted."
+      table:
+        thead:
+          muted_user: "Muted User"
+          actions: "Actions"
+        tbody:
+          unmute: "Unmute"
+          send_message: "Send message"
+
+    create:
+      notice: "You muted %{name}."
+      error: "%{name} could not be muted. %{full_message}."
+    destroy:
+      notice: "You unmuted %{name}."
+      error: "User could not be unmuted. Please try again."
   notes:
     index:
       title: "Notes submitted or commented on by %{user}"
index 2b67e360e871776b3420d88cdef499b7546f528d..110a67a4984a80b7f17182f92432b01f7d1a7e65 100644 (file)
@@ -282,9 +282,12 @@ OpenStreetMap::Application.routes.draw do
   # messages
   resources :messages, :only => [:create, :show, :destroy] do
     post :mark
+    patch :unmute
+
     match :reply, :via => [:get, :post]
     collection do
       get :inbox
+      get :muted
       get :outbox
     end
   end
@@ -293,6 +296,12 @@ OpenStreetMap::Application.routes.draw do
   get "/message/new/:display_name" => "messages#new", :as => "new_message"
   get "/message/read/:message_id", :to => redirect(:path => "/messages/%{message_id}")
 
+  # muting users
+  scope "/user/:display_name" do
+    resource :user_mute, :only => [:create, :destroy], :path => "mute"
+  end
+  resources :user_mutes, :only => [:index]
+
   # oauth admin pages (i.e: for setting up new clients, etc...)
   scope "/user/:display_name" do
     resources :oauth_clients
index 0cfa74cd70738ca851244a4830bf2b7bb0ed63fe..fe5aa5d897341348905eb8b349ad8bd601016107 100644 (file)
@@ -20,8 +20,8 @@ trace_file_storage: "test"
 trace_image_storage: "test"
 trace_icon_storage: "test"
 # Lower some rate limits for testing
-max_changeset_comments_per_hour: 30
-moderator_changeset_comments_per_hour: 60
+max_changeset_comments_per_hour: 10
+moderator_changeset_comments_per_hour: 15
 # Private key for signing id_tokens
 doorkeeper_signing_key: |
   -----BEGIN PRIVATE KEY-----
diff --git a/db/migrate/20231010201451_create_user_mutes.rb b/db/migrate/20231010201451_create_user_mutes.rb
new file mode 100644 (file)
index 0000000..8cb6ff8
--- /dev/null
@@ -0,0 +1,15 @@
+class CreateUserMutes < ActiveRecord::Migration[7.0]
+  def change
+    create_table :user_mutes do |t|
+      t.references :owner, :null => false, :index => false
+      t.references :subject, :null => false, :index => false
+
+      t.timestamps
+
+      t.foreign_key :users, :column => :owner_id
+      t.foreign_key :users, :column => :subject_id
+
+      t.index [:owner_id, :subject_id], :unique => true
+    end
+  end
+end
diff --git a/db/migrate/20231010203028_add_muted_flag_to_messages.rb b/db/migrate/20231010203028_add_muted_flag_to_messages.rb
new file mode 100644 (file)
index 0000000..e517aea
--- /dev/null
@@ -0,0 +1,5 @@
+class AddMutedFlagToMessages < ActiveRecord::Migration[7.0]
+  def change
+    add_column :messages, :muted, :boolean, :default => false, :null => false
+  end
+end
index ba60918f0ee1a93c8333aef7544fe52ed647cf0f..0563417cdf07f81577a51a8dab9770d8b57ea423 100644 (file)
@@ -951,7 +951,8 @@ CREATE TABLE public.messages (
     to_user_id bigint NOT NULL,
     to_user_visible boolean DEFAULT true NOT NULL,
     from_user_visible boolean DEFAULT true NOT NULL,
-    body_format public.format_enum DEFAULT 'markdown'::public.format_enum NOT NULL
+    body_format public.format_enum DEFAULT 'markdown'::public.format_enum NOT NULL,
+    muted boolean DEFAULT false NOT NULL
 );
 
 
@@ -1454,6 +1455,38 @@ CREATE SEQUENCE public.user_blocks_id_seq
 ALTER SEQUENCE public.user_blocks_id_seq OWNED BY public.user_blocks.id;
 
 
+--
+-- Name: user_mutes; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.user_mutes (
+    id bigint NOT NULL,
+    owner_id bigint NOT NULL,
+    subject_id bigint NOT NULL,
+    created_at timestamp(6) without time zone NOT NULL,
+    updated_at timestamp(6) without time zone NOT NULL
+);
+
+
+--
+-- Name: user_mutes_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.user_mutes_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+--
+-- Name: user_mutes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.user_mutes_id_seq OWNED BY public.user_mutes.id;
+
+
 --
 -- Name: user_preferences; Type: TABLE; Schema: public; Owner: -
 --
@@ -1835,6 +1868,13 @@ ALTER TABLE ONLY public.reports ALTER COLUMN id SET DEFAULT nextval('public.repo
 ALTER TABLE ONLY public.user_blocks ALTER COLUMN id SET DEFAULT nextval('public.user_blocks_id_seq'::regclass);
 
 
+--
+-- Name: user_mutes id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_mutes ALTER COLUMN id SET DEFAULT nextval('public.user_mutes_id_seq'::regclass);
+
+
 --
 -- Name: user_roles id; Type: DEFAULT; Schema: public; Owner: -
 --
@@ -2216,6 +2256,14 @@ ALTER TABLE ONLY public.user_blocks
     ADD CONSTRAINT user_blocks_pkey PRIMARY KEY (id);
 
 
+--
+-- Name: user_mutes user_mutes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_mutes
+    ADD CONSTRAINT user_mutes_pkey PRIMARY KEY (id);
+
+
 --
 -- Name: user_preferences user_preferences_pkey; Type: CONSTRAINT; Schema: public; Owner: -
 --
@@ -2734,6 +2782,13 @@ CREATE INDEX index_reports_on_user_id ON public.reports USING btree (user_id);
 CREATE INDEX index_user_blocks_on_user_id ON public.user_blocks USING btree (user_id);
 
 
+--
+-- Name: index_user_mutes_on_owner_id_and_subject_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX index_user_mutes_on_owner_id_and_subject_id ON public.user_mutes USING btree (owner_id, subject_id);
+
+
 --
 -- Name: messages_from_user_id_idx; Type: INDEX; Schema: public; Owner: -
 --
@@ -3107,6 +3162,14 @@ ALTER TABLE ONLY public.oauth_access_grants
     ADD CONSTRAINT fk_rails_330c32d8d9 FOREIGN KEY (resource_owner_id) REFERENCES public.users(id) NOT VALID;
 
 
+--
+-- Name: user_mutes fk_rails_591dad3359; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_mutes
+    ADD CONSTRAINT fk_rails_591dad3359 FOREIGN KEY (owner_id) REFERENCES public.users(id);
+
+
 --
 -- Name: oauth_access_tokens fk_rails_732cb83ab7; Type: FK CONSTRAINT; Schema: public; Owner: -
 --
@@ -3155,6 +3218,14 @@ ALTER TABLE ONLY public.oauth_applications
     ADD CONSTRAINT fk_rails_cc886e315a FOREIGN KEY (owner_id) REFERENCES public.users(id) NOT VALID;
 
 
+--
+-- Name: user_mutes fk_rails_e9dd4fb6c3; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_mutes
+    ADD CONSTRAINT fk_rails_e9dd4fb6c3 FOREIGN KEY (subject_id) REFERENCES public.users(id);
+
+
 --
 -- Name: oauth_access_tokens fk_rails_ee63f25419; Type: FK CONSTRAINT; Schema: public; Owner: -
 --
@@ -3514,6 +3585,8 @@ INSERT INTO "schema_migrations" (version) VALUES
 ('20231117170422'),
 ('20231101222146'),
 ('20231029151516'),
+('20231010203028'),
+('20231010201451'),
 ('20231010194809'),
 ('20231007141103'),
 ('20230830115220'),
index 28d755b24d4a40e74934d50600258764234d5803..81de3ef587402b35eeff71727e49b85da903da81 100755 (executable)
@@ -33,6 +33,6 @@ mail = Mail.new($stdin.read
 message = Message.from_mail(mail, from, to)
 message.save!
 
-UserMailer.message_notification(message).deliver
+UserMailer.message_notification(message).deliver if message.notify_recipient?
 
 exit 0
diff --git a/test/controllers/user_mutes_controller_test.rb b/test/controllers/user_mutes_controller_test.rb
new file mode 100644 (file)
index 0000000..cc22faa
--- /dev/null
@@ -0,0 +1,60 @@
+require "test_helper"
+
+class UserMutesControllerTest < ActionDispatch::IntegrationTest
+  def test_routes
+    assert_routing(
+      { :path => "/user/username/mute", :method => :post },
+      { :controller => "user_mutes", :action => "create", :display_name => "username" }
+    )
+    assert_routing(
+      { :path => "/user/username/mute", :method => :delete },
+      { :controller => "user_mutes", :action => "destroy", :display_name => "username" }
+    )
+    assert_routing(
+      { :path => "/user_mutes", :method => :get },
+      { :controller => "user_mutes", :action => "index" }
+    )
+  end
+
+  def test_index
+    user = create(:user)
+    user.mutes.create(:subject => create(:user))
+    session_for(user)
+
+    get user_mutes_path
+    assert_match "You have muted 1 User", @response.body
+  end
+
+  def test_create
+    user = create(:user)
+    session_for(user)
+
+    assert_equal 0, user.muted_users.count
+    subject = create(:user, :display_name => "Bob")
+    post user_mute_path(subject)
+    assert_match "You muted Bob", flash[:notice]
+
+    assert_equal 1, user.muted_users.count
+    assert_equal subject, user.muted_users.first
+
+    post user_mute_path(subject)
+    assert_match "Bob could not be muted. Is already muted", flash[:error]
+    assert_equal 1, user.muted_users.count
+  end
+
+  def test_destroy
+    user = create(:user)
+    session_for(user)
+
+    subject = create(:user, :display_name => "Bob")
+    user.mutes.create(:subject => subject)
+    assert_equal 1, user.muted_users.count
+
+    delete user_mute_path(subject)
+    assert_match "You unmuted Bob", flash[:notice]
+    assert_equal 0, user.muted_users.count
+
+    delete user_mute_path(subject)
+    assert_response :not_found
+  end
+end
index 4f8ee5437fc8a86bcb1897ebbe03b4722da3e78a..906c4dea9fada803fc6e15fe9718c7100ac949d9 100644 (file)
@@ -14,5 +14,9 @@ FactoryBot.define do
     trait :read do
       message_read { true }
     end
+
+    trait :muted do
+      muted { true }
+    end
   end
 end
diff --git a/test/factories/user_mute.rb b/test/factories/user_mute.rb
new file mode 100644 (file)
index 0000000..4beaf3a
--- /dev/null
@@ -0,0 +1,6 @@
+FactoryBot.define do
+  factory :user_mute do
+    owner :factory => :user
+    subject :factory => :user
+  end
+end
index 21e751bcaf6eeb7ebd8ec9cea54882253fe24e36..8ec0dc9bc049e2486458f50b650034e5d0f52beb 100644 (file)
@@ -18,149 +18,121 @@ class UserCreationTest < ActionDispatch::IntegrationTest
   end
 
   def test_create_user_form
-    I18n.with_locale "en" do
-      I18n.available_locales.each do |locale|
-        reset!
-        get "/user/new", :headers => { "HTTP_ACCEPT_LANGUAGE" => locale.to_s }
-        follow_redirect!
-        assert_response :success
-        assert_template "users/new"
-      end
-    end
+    get "/user/new"
+    follow_redirect!
+    assert_response :success
+    assert_template "users/new"
   end
 
   def test_user_create_submit_duplicate_email
-    I18n.with_locale "en" do
-      Locale.available.each do |locale|
-        dup_email = create(:user).email
-        display_name = "#{locale}_new_tester"
-        assert_difference("User.count", 0) do
-          assert_difference("ActionMailer::Base.deliveries.size", 0) do
-            perform_enqueued_jobs do
-              post "/user/new",
-                   :params => { :user => { :email => dup_email,
-                                           :email_confirmation => dup_email,
-                                           :display_name => display_name,
-                                           :pass_crypt => "testtest",
-                                           :pass_crypt_confirmation => "testtest" } },
-                   :headers => { "HTTP_ACCEPT_LANGUAGE" => locale.to_s }
-            end
-          end
+    dup_email = create(:user).email
+    display_name = "new_tester"
+    assert_difference("User.count", 0) do
+      assert_difference("ActionMailer::Base.deliveries.size", 0) do
+        perform_enqueued_jobs do
+          post "/user/new",
+               :params => { :user => { :email => dup_email,
+                                       :email_confirmation => dup_email,
+                                       :display_name => display_name,
+                                       :pass_crypt => "testtest",
+                                       :pass_crypt_confirmation => "testtest" } }
         end
-        assert_response :success
-        assert_template "users/new"
-        assert_equal locale.to_s, response.headers["Content-Language"]
-        assert_select "form"
-        assert_select "form > div > input.is-invalid#user_email"
-        assert_no_missing_translations
       end
     end
+    assert_response :success
+    assert_template "users/new"
+    assert_select "form"
+    assert_select "form > div > input.is-invalid#user_email"
   end
 
   def test_user_create_submit_duplicate_username
-    I18n.with_locale "en" do
-      I18n.available_locales.each do |locale|
-        dup_display_name = create(:user).display_name
-        email = "#{locale}_new_tester"
-        assert_difference("User.count", 0) do
-          assert_difference("ActionMailer::Base.deliveries.size", 0) do
-            perform_enqueued_jobs do
-              post "/user/new",
-                   :params => { :user => { :email => email,
-                                           :email_confirmation => email,
-                                           :display_name => dup_display_name,
-                                           :pass_crypt => "testtest",
-                                           :pass_crypt_confirmation => "testtest" } },
-                   :headers => { "HTTP_ACCEPT_LANGUAGE" => locale.to_s }
-            end
-          end
+    dup_display_name = create(:user).display_name
+    email = "new_tester"
+    assert_difference("User.count", 0) do
+      assert_difference("ActionMailer::Base.deliveries.size", 0) do
+        perform_enqueued_jobs do
+          post "/user/new",
+               :params => { :user => { :email => email,
+                                       :email_confirmation => email,
+                                       :display_name => dup_display_name,
+                                       :pass_crypt => "testtest",
+                                       :pass_crypt_confirmation => "testtest" } }
         end
-        assert_response :success
-        assert_template "users/new"
-        assert_select "form > div > input.is-invalid#user_display_name"
-        assert_no_missing_translations
       end
     end
+    assert_response :success
+    assert_template "users/new"
+    assert_select "form > div > input.is-invalid#user_display_name"
   end
 
   def test_user_create_success
-    I18n.with_locale "en" do
-      I18n.available_locales.each do |locale|
-        new_email = "#{locale}newtester@osm.org"
-        display_name = "#{locale}_new_tester"
-
-        assert_difference("User.count", 0) do
-          assert_difference("ActionMailer::Base.deliveries.size", 0) do
-            perform_enqueued_jobs do
-              post "/user/new",
-                   :params => { :user => { :email => new_email,
-                                           :email_confirmation => new_email,
-                                           :display_name => display_name,
-                                           :pass_crypt => "testtest",
-                                           :pass_crypt_confirmation => "testtest" } }
-            end
-          end
+    new_email = "newtester@osm.org"
+    display_name = "new_tester"
+
+    assert_difference("User.count", 0) do
+      assert_difference("ActionMailer::Base.deliveries.size", 0) do
+        perform_enqueued_jobs do
+          post "/user/new",
+               :params => { :user => { :email => new_email,
+                                       :email_confirmation => new_email,
+                                       :display_name => display_name,
+                                       :pass_crypt => "testtest",
+                                       :pass_crypt_confirmation => "testtest" } }
         end
+      end
+    end
 
-        assert_redirected_to "/user/terms"
-
-        assert_difference("User.count") do
-          assert_difference("ActionMailer::Base.deliveries.size", 1) do
-            perform_enqueued_jobs do
-              post "/user/save",
-                   :headers => { "HTTP_ACCEPT_LANGUAGE" => locale.to_s },
-                   :params => { :read_ct => 1, :read_tou => 1 }
-              follow_redirect!
-            end
-          end
+    assert_redirected_to "/user/terms"
+
+    assert_difference("User.count") do
+      assert_difference("ActionMailer::Base.deliveries.size", 1) do
+        perform_enqueued_jobs do
+          post "/user/save",
+               :params => { :read_ct => 1, :read_tou => 1 }
+          follow_redirect!
         end
+      end
+    end
 
-        # Check the e-mail
-        register_email = ActionMailer::Base.deliveries.first
+    # Check the e-mail
+    register_email = ActionMailer::Base.deliveries.first
 
-        assert_equal register_email.to.first, new_email
-        # Check that the confirm account url is correct
-        assert_match(/#{@url}/, register_email.body.to_s)
+    assert_equal register_email.to.first, new_email
+    # Check that the confirm account url is correct
+    assert_match(/#{@url}/, register_email.body.to_s)
 
-        # Check the page
-        assert_response :success
-        assert_template "confirmations/confirm"
+    # Check the page
+    assert_response :success
+    assert_template "confirmations/confirm"
 
-        ActionMailer::Base.deliveries.clear
-      end
-    end
+    ActionMailer::Base.deliveries.clear
   end
 
   def test_user_create_no_tou_failure
-    I18n.with_locale "en" do
-      I18n.available_locales.each do |locale|
-        new_email = "#{locale}newtester@osm.org"
-        display_name = "#{locale}_new_tester"
-
-        assert_difference("User.count", 0) do
-          assert_difference("ActionMailer::Base.deliveries.size", 0) do
-            perform_enqueued_jobs do
-              post "/user/new",
-                   :params => { :user => { :email => new_email,
-                                           :email_confirmation => new_email,
-                                           :display_name => display_name,
-                                           :pass_crypt => "testtest",
-                                           :pass_crypt_confirmation => "testtest" } }
-            end
-          end
-        end
-
-        assert_redirected_to "/user/terms"
+    new_email = "#newtester@osm.org"
+    display_name = "new_tester"
 
+    assert_difference("User.count", 0) do
+      assert_difference("ActionMailer::Base.deliveries.size", 0) do
         perform_enqueued_jobs do
-          post "/user/save",
-               :headers => { "HTTP_ACCEPT_LANGUAGE" => locale.to_s }
-          assert_redirected_to "/user/terms"
+          post "/user/new",
+               :params => { :user => { :email => new_email,
+                                       :email_confirmation => new_email,
+                                       :display_name => display_name,
+                                       :pass_crypt => "testtest",
+                                       :pass_crypt_confirmation => "testtest" } }
         end
-
-        ActionMailer::Base.deliveries.clear
       end
     end
+
+    assert_redirected_to "/user/terms"
+
+    perform_enqueued_jobs do
+      post "/user/save"
+      assert_redirected_to "/user/terms"
+    end
+
+    ActionMailer::Base.deliveries.clear
   end
 
   # Check that the user can successfully recover their password
index 97afad56e17f80c72ec2f71add8c9f03146fb4c3..83cc1251d02d05a8465e802cb34727c029579cdb 100644 (file)
@@ -175,6 +175,26 @@ class MessageTest < ActiveSupport::TestCase
     assert_equal "text", message.body_format
   end
 
+  def test_notify_recipient
+    message = create(:message)
+    assert_not_predicate message, :muted?
+    assert_predicate message, :notify_recipient?
+  end
+
+  def test_notify_recipient_for_muted_messages
+    message = create(:message, :muted)
+    assert_predicate message, :muted?
+    assert_not_predicate message, :notify_recipient?
+  end
+
+  def test_unmuting_a_muted_message
+    message = create(:message, :muted)
+    assert_predicate message, :muted?
+
+    message.unmute
+    assert_not_predicate message, :muted?
+  end
+
   private
 
   def make_message(char, count)
diff --git a/test/models/user_mute_test.rb b/test/models/user_mute_test.rb
new file mode 100644 (file)
index 0000000..ccc6811
--- /dev/null
@@ -0,0 +1,24 @@
+require "test_helper"
+
+class UserMuteTest < ActiveSupport::TestCase
+  def test_messages_by_muted_users_are_muted
+    user = create(:user)
+    muted_user = create(:user)
+    create(:user_mute, :owner => user, :subject => muted_user)
+
+    message = create(:message, :sender => muted_user, :recipient => user)
+    assert_predicate message, :muted?
+  end
+
+  def test_messages_by_admins_or_moderators_are_never_muted
+    user = create(:user)
+
+    [create(:administrator_user), create(:moderator_user)].each do |admin_or_moderator|
+      create(:user_mute, :owner => user, :subject => admin_or_moderator)
+
+      message = create(:message, :sender => admin_or_moderator, :recipient => user)
+
+      assert_not_predicate message, :muted?
+    end
+  end
+end
diff --git a/test/system/user_muting_test.rb b/test/system/user_muting_test.rb
new file mode 100644 (file)
index 0000000..ce43461
--- /dev/null
@@ -0,0 +1,44 @@
+require "application_system_test_case"
+
+class UserMutingTest < ApplicationSystemTestCase
+  # NB: loads helpers to verify mailer-related behaviour e.g. via assert_no_emails
+  include ActionMailer::TestHelper
+
+  test "users can mute and unmute other users" do
+    user = create(:user)
+    other_user = create(:user)
+    sign_in_as(user)
+
+    visit user_path(other_user)
+    click_link "Mute this User"
+    assert_content "You muted #{other_user.display_name}"
+
+    visit edit_account_path
+    assert_content "Muted Users"
+    click_link "Muted Users"
+    assert_content "You have muted 1 User"
+    click_link "Unmute"
+
+    assert_content "You unmuted #{other_user.display_name}"
+    refute_content "Muted Users"
+    assert_current_path edit_account_path
+  end
+
+  test "messages sent by muted users are set `muted` and do not cause notification emails" do
+    user = create(:user)
+    muted_user = create(:user)
+    create(:user_mute, :owner => user, :subject => muted_user)
+    sign_in_as(muted_user)
+
+    visit new_message_path(user)
+    fill_in "Subject", :with => "Hey Hey"
+    fill_in "Body", :with => "some message"
+
+    assert_no_emails do
+      click_button "Send"
+    end
+
+    message = Message.find_by(:sender => muted_user, :recipient => user)
+    assert_predicate message, :muted?
+  end
+end