diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9fb6a56c2..bff3cd2a0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -53,7 +53,8 @@ module ApplicationHelper name = h(user.name(options[:format])) if user.active? || (User.current.admin? && user.logged?) only_path = options[:only_path].nil? ? true : options[:only_path] - link_to name, user_url(user, :only_path => only_path), :class => user.css_classes + css_classes = options[:class] ? "#{user.css_classes} #{options[:class]}" : user.css_classes + link_to name, user_url(user, :only_path => only_path), :class => css_classes else name end @@ -1080,9 +1081,6 @@ module ApplicationHelper if p = Project.visible.find_by_id(oid) link = link_to_project(p, {:only_path => only_path}, :class => 'project') end - when 'user' - u = User.visible.find_by(:id => oid, :type => 'User') - link = link_to_user(u, :only_path => only_path) if u end elsif sep == ':' name = remove_double_quotes(identifier) @@ -1157,14 +1155,14 @@ module ApplicationHelper if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first link = link_to_project(p, {:only_path => only_path}, :class => 'project') end - when 'user' - u = User.visible.find_by("LOWER(login) = :s AND type = 'User'", :s => name.downcase) - link = link_to_user(u, :only_path => only_path) if u end - elsif sep == "@" - name = remove_double_quotes(identifier) - u = User.visible.find_by("LOWER(login) = :s AND type = 'User'", :s => name.downcase) - link = link_to_user(u, :only_path => only_path) if u + end + if link.nil? && $~ + user = User.mentioned_user($~.named_captures.symbolize_keys) + if user + css_classes = (user.notify_mentioned_user?(obj) ? 'notified' : nil) + link = link_to_user(user, :only_path => only_path, :class => css_classes) + end end end (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}")) diff --git a/app/models/comment.rb b/app/models/comment.rb index 3e7005280..f6b485958 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -24,6 +24,8 @@ class Comment < ActiveRecord::Base validates_presence_of :commented, :author, :content + acts_as_mentionable :attributes => ['content'] + after_create_commit :send_notification safe_attributes 'comments' diff --git a/app/models/document.rb b/app/models/document.rb index 4f562fc70..5cc89de4d 100644 --- a/app/models/document.rb +++ b/app/models/document.rb @@ -63,7 +63,7 @@ class Document < ActiveRecord::Base end def notified_users - project.notified_users.reject {|user| !visible?(user)} + project.notified_users.select {|user| user.allowed_to_view_notify_target?(self) } end private diff --git a/app/models/issue.rb b/app/models/issue.rb index 63bc8cabe..053179d2c 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -43,6 +43,7 @@ class Issue < ActiveRecord::Base acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed acts_as_customizable acts_as_watchable + acts_as_mentionable :attributes => ['description'] acts_as_searchable :columns => ['subject', "#{table_name}.description"], :preload => [:project, :status, :tracker], :scope => lambda {|options| options[:open_issues] ? self.open : self.all} @@ -1045,8 +1046,7 @@ class Issue < ActiveRecord::Base notified += project.users.preload(:preference).select(&:notify_about_high_priority_issues?) if priority.high? notified.uniq! # Remove users that can not view the issue - notified.reject! {|user| !visible?(user)} - notified + notified.select {|user| user.allowed_to_view_notify_target?(self)} end # Returns the email addresses that should be notified diff --git a/app/models/journal.rb b/app/models/journal.rb index 191fac972..cca190cec 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -29,13 +29,13 @@ class Journal < ActiveRecord::Base has_many :details, :class_name => "JournalDetail", :dependent => :delete_all, :inverse_of => :journal attr_accessor :indice + acts_as_mentionable :attributes => ['notes'] acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" }, :description => :notes, :author => :user, :group => :issue, :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' }, :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}} - acts_as_activity_provider :type => 'issues', :author_key => :user_id, :scope => preload({:issue => :project}, :user). @@ -145,10 +145,7 @@ class Journal < ActiveRecord::Base def notified_users notified = journalized.notified_users - if private_notes? - notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)} - end - notified + notified.select{ |u| u.allowed_to_view_notify_target?(self) } end def recipients diff --git a/app/models/mailer.rb b/app/models/mailer.rb index 550c149cb..065c42f60 100644 --- a/app/models/mailer.rb +++ b/app/models/mailer.rb @@ -93,6 +93,7 @@ class Mailer < ActionMailer::Base # Mailer.deliver_issue_add(issue) def self.deliver_issue_add(issue) users = issue.notified_users | issue.notified_watchers + users -= issue.mentioned_users_with_latest_changes users.each do |user| issue_add(user, issue).deliver_later end @@ -131,6 +132,9 @@ class Mailer < ActionMailer::Base users.select! do |user| journal.notes? || journal.visible_details(user).any? end + users -= journal.mentioned_users_with_latest_changes + users -= journal.issue.mentioned_users_with_latest_changes + users.each do |user| issue_edit(user, journal).deliver_later end @@ -221,6 +225,7 @@ class Mailer < ActionMailer::Base # Mailer.deliver_news_added(news) def self.deliver_news_added(news) users = news.notified_users | news.notified_watchers_for_added_news + users -= news.mentioned_users_with_latest_changes users.each do |user| news_added(user, news).deliver_later end @@ -248,6 +253,7 @@ class Mailer < ActionMailer::Base def self.deliver_news_comment_added(comment) news = comment.commented users = news.notified_users | news.notified_watchers + users -= comment.mentioned_users_with_latest_changes users.each do |user| news_comment_added(user, comment).deliver_later end @@ -275,6 +281,7 @@ class Mailer < ActionMailer::Base users = message.notified_users users |= message.root.notified_watchers users |= message.board.notified_watchers + users -= message.mentioned_users_with_latest_changes users.each do |user| message_posted(user, message).deliver_later @@ -529,6 +536,22 @@ class Mailer < ActionMailer::Base end end + def mail_to_mentioned_users(user, obj, contents) + @contents = contents + mail :to => user, + :subject => "You are mentioned by #{obj.try(:author) || obj.user} in #{obj.class}##{obj.id}" + end + + # Notifies mentioned users. + # + # Example: + # Mailer.deliver_mail_to_mentioned_users(users, obj, content) + def self.deliver_mail_to_mentioned_users(users, obj, content) + users.each do |user| + mail_to_mentioned_users(user, obj, content).deliver_later + end + end + # Build a test email to user. def test_email(user) @url = url_for(:controller => 'welcome') diff --git a/app/models/message.rb b/app/models/message.rb index f48785447..a1e1221c3 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -114,7 +114,7 @@ class Message < ActiveRecord::Base end def notified_users - project.notified_users.reject {|user| !visible?(user)} + project.notified_users.select {|user| user.allowed_to_view_notify_target?(self) } end private diff --git a/app/models/news.rb b/app/models/news.rb index 3ad43bff7..3069d202c 100644 --- a/app/models/news.rb +++ b/app/models/news.rb @@ -31,6 +31,7 @@ class News < ActiveRecord::Base :delete_permission => :manage_news acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"], :preload => :project + acts_as_mentionable :attributes => ['description'] acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}} acts_as_activity_provider :scope => preload(:project, :author), :author_key => :author_id @@ -56,7 +57,7 @@ class News < ActiveRecord::Base end def notified_users - project.users.select {|user| user.notify_about?(self) && user.allowed_to?(:view_news, project)} + project.users.select {|user| user.notify_about?(self) && user.allowed_to_view_notify_target?(self)} end def recipients diff --git a/app/models/user.rb b/app/models/user.rb index 76f479bcd..09839d221 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -823,6 +823,47 @@ class User < Principal RequestStore.store[:current_user] ||= User.anonymous end + # Return the mentioned user to based on the match data + # of ApplicationHelper::LINKS_RE. + # user:jsmith -> Link to user with login jsmith + # @jsmith -> Link to user with login jsmith + # user#2 -> Link to user with id 2 + def self.mentioned_user(match_data) + return nil if match_data[:esc] + sep = match_data[:sep1] || match_data[:sep2] || match_data[:sep3] || match_data[:sep4] + identifier = match_data[:identifier1] || match_data[:identifier2] || match_data[:identifier3] + prefix = match_data[:prefix] + if ['#', '##'].include?(sep) && prefix == 'user' + User.visible.find_by(:id => identifier.to_i, :type => 'User') + elsif sep == '@' || (sep == ':' && prefix == 'user') + name = identifier.gsub(%r{^"(.*)"$}, "\\1") + User.find_by_login(CGI.unescapeHTML(name).downcase) + end + end + + # Return true if notify the mentioned user. + def notify_mentioned_user?(object) + self.active? && + self.mail.present? && + self.mail_notification.present? && self.mail_notification != 'none' && + self.allowed_to_view_notify_target?(object) + end + + # Return true if the user is allowed to view the notify target. + def allowed_to_view_notify_target?(object) + case object + when Journal + self.allowed_to_view_notify_target?(object.journalized) && + (!object.private_notes? || self.allowed_to?(:view_private_notes, object.journalized.project)) + when Comment + self.allowed_to_view_notify_target?(object.commented) + when nil + false + else + object.visible?(self) + end + end + # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only # one anonymous user per database. def self.anonymous diff --git a/app/models/wiki_content.rb b/app/models/wiki_content.rb index d38c5ca7d..ef2c28c5a 100644 --- a/app/models/wiki_content.rb +++ b/app/models/wiki_content.rb @@ -53,7 +53,7 @@ class WikiContent < ActiveRecord::Base end def notified_users - project.notified_users.reject {|user| !visible?(user)} + project.notified_users.select {|user| user.allowed_to_view_notify_target?(self) } end # Returns the mail addresses of users that should be notified diff --git a/app/views/mailer/mail_to_mentioned_users.html.erb b/app/views/mailer/mail_to_mentioned_users.html.erb new file mode 100644 index 000000000..ec63cf874 --- /dev/null +++ b/app/views/mailer/mail_to_mentioned_users.html.erb @@ -0,0 +1,3 @@ +<% @contents.each do |key, content| %> +

<%= textilizable content %>

+<% end %> \ No newline at end of file diff --git a/app/views/mailer/mail_to_mentioned_users.text.erb b/app/views/mailer/mail_to_mentioned_users.text.erb new file mode 100644 index 000000000..53a7c2eb3 --- /dev/null +++ b/app/views/mailer/mail_to_mentioned_users.text.erb @@ -0,0 +1,3 @@ +<% @contents.each do |key, content| %> +<%= textilizable content %> +<% end %> \ No newline at end of file diff --git a/lib/plugins/acts_as_mentionable/init.rb b/lib/plugins/acts_as_mentionable/init.rb new file mode 100644 index 000000000..248383f3c --- /dev/null +++ b/lib/plugins/acts_as_mentionable/init.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Include hook code here +require File.dirname(__FILE__) + '/lib/acts_as_mentionable' +ActiveRecord::Base.send(:include, Redmine::Acts::Mentionable) diff --git a/lib/plugins/acts_as_mentionable/lib/acts_as_mentionable.rb b/lib/plugins/acts_as_mentionable/lib/acts_as_mentionable.rb new file mode 100644 index 000000000..f4f884a98 --- /dev/null +++ b/lib/plugins/acts_as_mentionable/lib/acts_as_mentionable.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006-2019 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +module Redmine + module Acts + module Mentionable + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + def acts_as_mentionable(options = {}) + return if self.included_modules.include?(Redmine::Acts::Mentionable::InstanceMethods) + + cattr_accessor :mentionable_attributes + self.mentionable_attributes = options[:attributes] + + send :include, Redmine::Acts::Mentionable::InstanceMethods + + before_save :was_new_record? + after_save :notify_mentioned_users + end + end + + module InstanceMethods + def self.included(base) + base.extend ClassMethods + end + + def was_new_record? + @was_new_record = self.new_record? + end + + def notify_mentioned_users + attribute_values = mentionable_attributes.map{|attr| [attr, self.saved_changes[attr][1]] if self.saved_changes[attr] }.compact.to_h + users = mentioned_users_with_latest_changes + Mailer.deliver_mail_to_mentioned_users(users, self, attribute_values) if users.present? + end + + def mentioned_users_with_latest_changes + changes = self.saved_changes + if @was_new_record + values = mentionable_attributes.map{|attr| changes[attr] && changes[attr][1] }.compact + users = mentioned_users(values) + else + new_values = mentionable_attributes.map{|attr| changes[attr] && changes[attr][1] }.compact + old_values = mentionable_attributes.map{|attr| changes[attr] && changes[attr][0] }.compact + users = mentioned_users(new_values) - mentioned_users(old_values) + end + users + end + + def mentioned_users(values) + users = [] + values.each do |value| + value.scan(ApplicationHelper::LINKS_RE) do |_| + target = User.mentioned_user($~.named_captures.symbolize_keys) + next if target.blank? || users.include?(target) + users << target if target.notify_mentioned_user?(self) + end + end + users.uniq + end + + module ClassMethods + end + end + end + end +end diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index 346705efe..f4a578eb9 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -137,6 +137,7 @@ div.modal .box p {margin: 0.3em 0;} a, a:link, a:visited{ color: #169; text-decoration: none; } a:hover, a:active{ color: #c61a1a; text-decoration: underline;} a img{ border: 0; } +a.user.notified, a.user.notified:link, a.user.notified:visited {padding: 2px; border-radius: 3px; background-color: #bae9f5} a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: #999; text-decoration: line-through; } a.project.closed, a.project.closed:link, a.project.closed:visited { color: #999; }