From fe2904bf1d47126bb69301a51494c00c209e94f4 Mon Sep 17 00:00:00 2001 From: Katsuya HIDAKA Date: Fri, 25 Apr 2025 10:01:37 +0900 Subject: Adds reaction feature to issues, news, and forums --- app/assets/images/icons.svg | 7 ++ app/assets/javascripts/application-legacy.js | 8 +- app/assets/stylesheets/application.css | 26 +++++ app/controllers/messages_controller.rb | 2 +- app/controllers/news_controller.rb | 2 +- app/controllers/reactions_controller.rb | 55 +++++++++++ app/helpers/issues_helper.rb | 1 + app/helpers/journals_helper.rb | 3 + app/helpers/messages_helper.rb | 1 + app/helpers/news_helper.rb | 1 + app/helpers/reactions_helper.rb | 96 +++++++++++++++++++ app/models/comment.rb | 4 + app/models/issue.rb | 4 +- app/models/journal.rb | 1 + app/models/message.rb | 2 + app/models/news.rb | 2 + app/models/reaction.rb | 54 +++++++++++ app/models/user.rb | 1 + app/views/issues/show.html.erb | 4 + app/views/messages/show.html.erb | 4 + app/views/news/show.html.erb | 16 +++- app/views/reactions/_replace_button.js.erb | 7 ++ app/views/reactions/create.js.erb | 1 + app/views/reactions/destroy.js.erb | 1 + app/views/settings/_general.html.erb | 7 ++ config/icon_source.yml | 7 +- config/locales/en.yml | 6 ++ config/locales/ja.yml | 6 ++ config/routes.rb | 2 + config/settings.yml | 2 + db/migrate/20250423065135_create_reactions.rb | 11 +++ lib/redmine/reaction.rb | 73 ++++++++++++++ 32 files changed, 405 insertions(+), 12 deletions(-) create mode 100644 app/controllers/reactions_controller.rb create mode 100644 app/helpers/reactions_helper.rb create mode 100644 app/models/reaction.rb create mode 100644 app/views/reactions/_replace_button.js.erb create mode 100644 app/views/reactions/create.js.erb create mode 100644 app/views/reactions/destroy.js.erb create mode 100644 db/migrate/20250423065135_create_reactions.rb create mode 100644 lib/redmine/reaction.rb diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg index 55e3a042d..2b0c9a41b 100644 --- a/app/assets/images/icons.svg +++ b/app/assets/images/icons.svg @@ -459,6 +459,13 @@ + + + + + + + diff --git a/app/assets/javascripts/application-legacy.js b/app/assets/javascripts/application-legacy.js index 265ac39c6..286e3e2e6 100644 --- a/app/assets/javascripts/application-legacy.js +++ b/app/assets/javascripts/application-legacy.js @@ -1222,8 +1222,8 @@ function setupWikiTableSortableHeader() { }); } -function setupHoverTooltips() { - $("[title]:not(.no-tooltip)").tooltip({ +function setupHoverTooltips(container) { + $(container || 'body').find("[title]:not(.no-tooltip)").tooltip({ show: { delay: 400 }, @@ -1233,7 +1233,9 @@ function setupHoverTooltips() { } }); } - +function removeHoverTooltips(container) { + $(container || 'body').find("[title]:not(.no-tooltip)").tooltip('destroy') +} $(function() { setupHoverTooltips(); }); function inlineAutoComplete(element) { diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d6288ad4f..1556bf82a 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -2052,6 +2052,32 @@ color: #555; text-shadow: 1px 1px 0 #fff; img.filecontent.image {background-image: url(/transparent.png);} +/* Reaction styles */ +.reaction-button.reacted .icon-svg { + fill: #126fa7; + stroke: none; +} +.reaction-button.reacted:hover .icon-svg { + fill: #c61a1a; +} +.reaction-button .icon-label { + margin-left: 3px; + margin-bottom: -1px; +} +div.issue.details .reaction { + float: right; + font-size: 0.9em; + margin-top: 0.5em; +} +div.message .reaction { + float: right; + font-size: 0.9em; +} +div.news .reaction { + float: right; + font-size: 0.9em; +} + /* Custom JQuery styles */ .ui-autocomplete, .ui-menu { border-radius: 2px; diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index 22daf9f90..329e22530 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -49,7 +49,7 @@ class MessagesController < ApplicationController reorder("#{Message.table_name}.created_on ASC, #{Message.table_name}.id ASC"). limit(@reply_pages.per_page). offset(@reply_pages.offset). - to_a + load_with_reactions @reply = Message.new(:subject => "RE: #{@message.subject}") render :action => "show", :layout => false if request.xhr? diff --git a/app/controllers/news_controller.rb b/app/controllers/news_controller.rb index 06240e359..139c94c7b 100644 --- a/app/controllers/news_controller.rb +++ b/app/controllers/news_controller.rb @@ -67,7 +67,7 @@ class NewsController < ApplicationController end def show - @comments = @news.comments.to_a + @comments = @news.comments.load_with_reactions @comments.reverse! if User.current.wants_comments_in_reverse_order? end diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb new file mode 100644 index 000000000..a7003afc3 --- /dev/null +++ b/app/controllers/reactions_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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. + +class ReactionsController < ApplicationController + before_action :check_enabled + + before_action :require_login + before_action :set_object, :authorize_reactable + + def create + @reaction = @object.reactions.find_or_create_by!(user: User.current) + end + + def destroy + @reaction = @object.reactions.by(User.current).find_by(id: params[:id]) + @reaction&.destroy + end + + private + + def check_enabled + render_403 unless Setting.reactions_enabled? + end + + def set_object + object_type = params[:object_type] + + unless Redmine::Reaction::REACTABLE_TYPES.include?(object_type) + render_403 + return + end + + @object = object_type.constantize.find(params[:object_id]) + end + + def authorize_reactable + render_403 unless @object.visible?(User.current) + end +end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 6586a1b7e..ce3607a5d 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -22,6 +22,7 @@ module IssuesHelper include Redmine::Export::PDF::IssuesPdfHelper include IssueStatusesHelper include QueriesHelper + include ReactionsHelper def issue_list(issues, &) ancestors = [] diff --git a/app/helpers/journals_helper.rb b/app/helpers/journals_helper.rb index 6c22fc4ca..66f81033e 100644 --- a/app/helpers/journals_helper.rb +++ b/app/helpers/journals_helper.rb @@ -19,6 +19,7 @@ module JournalsHelper include Redmine::QuoteReply::Helper + include ReactionsHelper # Returns the attachments of a journal that are displayed as thumbnails def journal_thumbnail_attachments(journal) @@ -41,6 +42,8 @@ module JournalsHelper end if journal.notes.present? + links << reaction_button(journal) + if options[:reply_links] url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice) links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true) diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index fd9ba3bcb..92f788d0c 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -19,4 +19,5 @@ module MessagesHelper include Redmine::QuoteReply::Helper + include ReactionsHelper end diff --git a/app/helpers/news_helper.rb b/app/helpers/news_helper.rb index a5c50fdfd..cd7b6734a 100644 --- a/app/helpers/news_helper.rb +++ b/app/helpers/news_helper.rb @@ -18,4 +18,5 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. module NewsHelper + include ReactionsHelper end diff --git a/app/helpers/reactions_helper.rb b/app/helpers/reactions_helper.rb new file mode 100644 index 000000000..bb1a97f90 --- /dev/null +++ b/app/helpers/reactions_helper.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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 ReactionsHelper + def reaction_button(object, reaction = nil) + return unless Setting.reactions_enabled? && object.visible?(User.current) + + reaction ||= object.reaction_by(User.current) + + count = object.reaction_count + user_names = object.reaction_user_names + + tooltip = build_reaction_tooltip(user_names, count) + + if User.current.logged? + if reaction&.persisted? + reaction_button_reacted(object, reaction, count, tooltip) + else + reaction_button_not_reacted(object, count, tooltip) + end + else + reaction_button_readonly(object, count, tooltip) + end + end + + def reaction_id_for(object) + dom_id(object, :reaction) + end + + private + + def reaction_button_reacted(object, reaction, count, tooltip) + reaction_button_wrapper object do + link_to( + sprite_icon('thumb-up-filled', count), + reaction_path(reaction, object_type: object.class.name, object_id: object), + remote: true, method: :delete, + class: ['icon', 'reaction-button', 'reacted'], + title: tooltip + ) + end + end + + def reaction_button_not_reacted(object, count, tooltip) + reaction_button_wrapper object do + link_to( + sprite_icon('thumb-up', count), + reactions_path(object_type: object.class.name, object_id: object), + remote: true, method: :post, + class: 'icon reaction-button', + title: tooltip + ) + end + end + + def reaction_button_readonly(object, count, tooltip) + reaction_button_wrapper object do + tag.span(class: 'icon reaction-button', title: tooltip) do + sprite_icon('thumb-up', count) + end + end + end + + def reaction_button_wrapper(object, &) + tag.span(data: { 'reaction-button-id': reaction_id_for(object) }, &) + end + + def build_reaction_tooltip(user_names, count) + return if count.zero? + + display_user_names = user_names.dup + + if count > Redmine::Reaction::DISPLAY_REACTION_USERS_LIMIT + others = count - Redmine::Reaction::DISPLAY_REACTION_USERS_LIMIT + display_user_names << I18n.t(:reaction_text_x_other_users, count: others) + end + + display_user_names.to_sentence(locale: I18n.locale) + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index 79eb59748..483073c5b 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -19,6 +19,8 @@ class Comment < ApplicationRecord include Redmine::SafeAttributes + include Redmine::Reaction::Reactable + belongs_to :commented, :polymorphic => true, :counter_cache => true belongs_to :author, :class_name => 'User' @@ -28,6 +30,8 @@ class Comment < ApplicationRecord safe_attributes 'comments' + delegate :visible?, to: :commented + def comments=(arg) self.content = arg end diff --git a/app/models/issue.rb b/app/models/issue.rb index ac3b40bf1..4c8a4b1c9 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -25,6 +25,7 @@ class Issue < ApplicationRecord before_validation :clear_disabled_fields before_save :set_parent_id include Redmine::NestedSet::IssueNestedSet + include Redmine::Reaction::Reactable belongs_to :project belongs_to :tracker @@ -916,7 +917,8 @@ class Issue < ApplicationRecord result = journals. preload(:details). preload(:user => :email_address). - reorder(:created_on, :id).to_a + reorder(:created_on, :id). + load_with_reactions result.each_with_index {|j, i| j.indice = i + 1} diff --git a/app/models/journal.rb b/app/models/journal.rb index 039b182e2..12f2beec8 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -19,6 +19,7 @@ class Journal < ApplicationRecord include Redmine::SafeAttributes + include Redmine::Reaction::Reactable belongs_to :journalized, :polymorphic => true # added as a quick fix to allow eager loading of the polymorphic association diff --git a/app/models/message.rb b/app/models/message.rb index c7f78d2d9..9ac88c7d1 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -19,6 +19,8 @@ class Message < ApplicationRecord include Redmine::SafeAttributes + include Redmine::Reaction::Reactable + belongs_to :board belongs_to :author, :class_name => 'User' acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC" diff --git a/app/models/news.rb b/app/models/news.rb index 40cd63db9..174e4c5ac 100644 --- a/app/models/news.rb +++ b/app/models/news.rb @@ -19,6 +19,8 @@ class News < ApplicationRecord include Redmine::SafeAttributes + include Redmine::Reaction::Reactable + belongs_to :project belongs_to :author, :class_name => 'User' has_many :comments, lambda {order("created_on")}, :as => :commented, :dependent => :delete_all diff --git a/app/models/reaction.rb b/app/models/reaction.rb new file mode 100644 index 000000000..b19e7915a --- /dev/null +++ b/app/models/reaction.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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. + +class Reaction < ApplicationRecord + belongs_to :reactable, polymorphic: true + belongs_to :user + + validates :reactable_type, inclusion: { in: Redmine::Reaction::REACTABLE_TYPES } + + scope :by, ->(user) { where(user: user) } + + # Returns a mapping of reactable IDs to an array of user names + # + # Returns: + # { + # 1 => ["Alice", "Bob"], + # 2 => ["Charlie"], + # ... + # } + def self.users_map_for_reactables(reactable_type, reactable_ids) + reactions = preload(:user) + .select(:reactable_id, :user_id) + .where(reactable_type: reactable_type, reactable_id: reactable_ids) + .order(id: :desc) + + reactable_user_pairs = reactions.map do |reaction| + [reaction.reactable_id, reaction.user.name] + end + + # Group by reactable_id and transform values to extract only user name + # [[1, "Alice"], [1, "Bob"], [2, "Charlie"], ...] + # => + # { 1 => ["Alice", "Bob"], 2 => ["Charlie"], ...} + reactable_user_pairs + .group_by(&:first) + .transform_values { |pairs| pairs.map(&:last) } + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 1839613c7..8ba3c39e9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -93,6 +93,7 @@ class User < Principal has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token' has_one :email_address, lambda {where :is_default => true}, :autosave => true has_many :email_addresses, :dependent => :delete_all + has_many :reactions, dependent: :delete_all belongs_to :auth_source scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")} diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb index 8f732032a..37fee024b 100644 --- a/app/views/issues/show.html.erb +++ b/app/views/issues/show.html.erb @@ -39,6 +39,10 @@
<%= render_issue_subject_with_tree(@issue) %> +
+ +
+ <%= reaction_button @issue %>

<%= authoring @issue.created_on, @issue.author %>. diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb index b265cc962..b62709afa 100644 --- a/app/views/messages/show.html.erb +++ b/app/views/messages/show.html.erb @@ -27,6 +27,9 @@

<%= avatar(@topic.author) %><%= @topic.subject %>

+
+ <%= reaction_button @topic %> +

<%= authoring @topic.created_on, @topic.author %>

<%= textilizable(@topic, :content) %> @@ -44,6 +47,7 @@ <% @replies.each do |message| %>
">
+ <%= reaction_button message %> <%= quote_reply( url_for(:action => 'quote', :id => message, :format => 'js'), "#message-#{message.id} .wiki", diff --git a/app/views/news/show.html.erb b/app/views/news/show.html.erb index d07a09eb7..601f12072 100644 --- a/app/views/news/show.html.erb +++ b/app/views/news/show.html.erb @@ -22,12 +22,17 @@
<% end %> -

<% unless @news.summary.blank? %><%= @news.summary %>
<% end %> -<%= authoring @news.created_on, @news.author %>

-
-<%= textilizable(@news, :description) %> +
+
+ <%= reaction_button @news %> +
+

<% unless @news.summary.blank? %><%= @news.summary %>
<% end %> + <%= authoring @news.created_on, @news.author %>

+
+ <%= textilizable(@news, :description) %> +
+ <%= link_to_attachments @news %>
-<%= link_to_attachments @news %>
@@ -38,6 +43,7 @@ <% @comments.each do |comment| %> <% next if comment.new_record? %>
+ <%= reaction_button comment %> <%= link_to_if_authorized sprite_icon('del', l(:button_delete)), { :controller => 'comments', :action => 'destroy', :id => @news, :comment_id => comment}, :data => {:confirm => l(:text_are_you_sure)}, :method => :delete, :title => l(:button_delete), diff --git a/app/views/reactions/_replace_button.js.erb b/app/views/reactions/_replace_button.js.erb new file mode 100644 index 000000000..642c0581d --- /dev/null +++ b/app/views/reactions/_replace_button.js.erb @@ -0,0 +1,7 @@ +(() => { + const button = $('[data-reaction-button-id=<%= reaction_id_for @object %>]'); + + removeHoverTooltips(button); + button.html($('<%=j reaction_button @object, @reaction %>').children()); + setupHoverTooltips(button); +})(); diff --git a/app/views/reactions/create.js.erb b/app/views/reactions/create.js.erb new file mode 100644 index 000000000..20f3cc7ed --- /dev/null +++ b/app/views/reactions/create.js.erb @@ -0,0 +1 @@ +<%= render 'replace_button' %> diff --git a/app/views/reactions/destroy.js.erb b/app/views/reactions/destroy.js.erb new file mode 100644 index 000000000..20f3cc7ed --- /dev/null +++ b/app/views/reactions/destroy.js.erb @@ -0,0 +1 @@ +<%= render 'replace_button' %> diff --git a/app/views/settings/_general.html.erb b/app/views/settings/_general.html.erb index 043067f18..ad15a5881 100644 --- a/app/views/settings/_general.html.erb +++ b/app/views/settings/_general.html.erb @@ -37,6 +37,13 @@

<%= setting_text_field :feeds_limit, :size => 6 %>

+

+ <%= setting_check_box :reactions_enabled %> + + <%= l(:reaction_text_enabling_reactions) %> + +

+ <%= call_hook(:view_settings_general_form) %>
diff --git a/config/icon_source.yml b/config/icon_source.yml index dc1803cdc..6fc7c5567 100644 --- a/config/icon_source.yml +++ b/config/icon_source.yml @@ -221,4 +221,9 @@ - name: unwatch svg: eye-off - name: copy-pre-content - svg: clipboard \ No newline at end of file + svg: clipboard +- name: thumb-up + svg: thumb-up +- name: thumb-up-filled + svg: thumb-up + style: filled diff --git a/config/locales/en.yml b/config/locales/en.yml index 819846e1a..566eab262 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -528,6 +528,7 @@ en: setting_twofa: Two-factor authentication setting_related_issues_default_columns: Related and sub issues list defaults setting_display_related_issues_table_headers: Show table headers + setting_reactions_enabled: Enable reactions permission_add_project: Create project permission_add_subprojects: Create subprojects @@ -1432,3 +1433,8 @@ en: text_project_destroy_enter_identifier: "To confirm, please enter the project's identifier (%{identifier}) below." field_name_or_email_or_login: Name, email or login setting_wiki_tablesort_enabled: Javascript based table sorting in wiki content + + reaction_text_enabling_reactions: "This enables reactions in issues, forums, and news." + reaction_text_x_other_users: + one: "1 other" + other: "%{count} others" diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 59b3ce341..e445a9916 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -1458,3 +1458,9 @@ ja: setting_display_related_issues_table_headers: Show table headers error_can_not_remove_role_reason_members_html: "

The following projects have members with this role:
%{projects}

" + + setting_reactions_enabled: リアクション機能を有効にする + reaction_text_enabling_reactions: "チケット、フォーラム、ニュースでリアクション機能を有効にします。" + reaction_text_x_other_users: + one: 他1人 + other: "他%{count}人" diff --git a/config/routes.rb b/config/routes.rb index 89927bee3..20a7e826a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,8 @@ Rails.application.routes.draw do end end + resources :reactions, only: [:create, :destroy] + get '/projects/:project_id/issues/gantt', :to => 'gantts#show', :as => 'project_gantt' get '/issues/gantt', :to => 'gantts#show' diff --git a/config/settings.yml b/config/settings.yml index a0c256cdd..cda40fa38 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -363,3 +363,5 @@ show_status_changes_in_mail_subject: default: 1 wiki_tablesort_enabled: default: 1 +reactions_enabled: + default: 1 diff --git a/db/migrate/20250423065135_create_reactions.rb b/db/migrate/20250423065135_create_reactions.rb new file mode 100644 index 000000000..56f345e1b --- /dev/null +++ b/db/migrate/20250423065135_create_reactions.rb @@ -0,0 +1,11 @@ +class CreateReactions < ActiveRecord::Migration[7.2] + def change + create_table :reactions do |t| + t.references :reactable, polymorphic: true, null: false + t.references :user, null: false + t.timestamps null: false + end + add_index :reactions, [:reactable_type, :reactable_id, :user_id], unique: true + add_index :reactions, [:reactable_type, :reactable_id, :id] + end +end diff --git a/lib/redmine/reaction.rb b/lib/redmine/reaction.rb new file mode 100644 index 000000000..b033f4ace --- /dev/null +++ b/lib/redmine/reaction.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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 Reaction + # Types of objects that can have reactions + REACTABLE_TYPES = %w(Journal Issue Message News Comment) + + # Maximum number of users to display in the reaction button tooltip + DISPLAY_REACTION_USERS_LIMIT = 10 + + module Reactable + extend ActiveSupport::Concern + + included do + has_many :reactions, -> { order(id: :desc) }, as: :reactable, dependent: :delete_all + has_many :reaction_users, through: :reactions, source: :user + + attr_writer :reaction_user_names, :reaction_count + end + + class_methods do + def load_with_reactions + objects = all.to_a + + return objects unless Setting.reactions_enabled? + + object_users_map = ::Reaction.users_map_for_reactables(self.name, objects.map(&:id)) + + objects.each do |object| + all_user_names = object_users_map[object.id] || [] + + object.reaction_count = all_user_names.size + object.reaction_user_names = all_user_names.take(DISPLAY_REACTION_USERS_LIMIT) + end + objects + end + end + + def reaction_user_names + @reaction_user_names || reaction_users.take(DISPLAY_REACTION_USERS_LIMIT).map(&:name) + end + + def reaction_count + @reaction_count || reaction_users.size + end + + def reaction_by(user) + if reactions.loaded? + reactions.find { _1.user_id == user.id } + else + reactions.find_by(user: user) + end + end + end + end +end -- 2.49.0