Add basic structures for UserMute and Message muting logic

Including models, migration, controllers, views & locales.
This commit is contained in:
Gregory Igelmund 2023-12-13 13:06:28 -05:00
parent 6eb29bf6d4
commit efc61f1315
18 changed files with 346 additions and 12 deletions

View 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

View 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
##

View file

@ -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

View 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

View 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

34
app/models/user_mute.rb Normal file
View file

@ -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

View file

@ -14,5 +14,10 @@
<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 %>

View file

@ -3,7 +3,9 @@
<% 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 }.each do |i18n_key, path| %>
<% { ".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>

View 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>

View file

@ -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" } %>

View file

@ -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 %>

View file

@ -112,6 +112,16 @@
<%= 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 %>

View 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"
@ -1695,6 +1699,7 @@ en:
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}"
@ -1708,14 +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"
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:
@ -1727,12 +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:
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:
@ -2562,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"
@ -2749,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
@ -2939,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}"

View 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

View file

@ -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

View file

@ -0,0 +1,5 @@
class AddMutedFlagToMessages < ActiveRecord::Migration[7.0]
def change
add_column :messages, :muted, :boolean, :default => false, :null => false
end
end

View 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'),

View file

@ -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