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 @@
       <path d="M19 15v6h3"/>
       <path d="M11 21v-6l2.5 3l2.5 -3v6"/>
     </symbol>
+    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--thumb-up">
+      <path d="M7 11v8a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-7a1 1 0 0 1 1 -1h3a4 4 0 0 0 4 -4v-1a2 2 0 0 1 4 0v5h3a2 2 0 0 1 2 2l-1 5a2 3 0 0 1 -2 2h-7a3 3 0 0 1 -3 -3"/>
+    </symbol>
+    <symbol viewBox="0 0 24 24" id="icon--thumb-up-filled">
+      <path d="M13 3a3 3 0 0 1 2.995 2.824l.005 .176v4h2a3 3 0 0 1 2.98 2.65l.015 .174l.005 .176l-.02 .196l-1.006 5.032c-.381 1.626 -1.502 2.796 -2.81 2.78l-.164 -.008h-8a1 1 0 0 1 -.993 -.883l-.007 -.117l.001 -9.536a1 1 0 0 1 .5 -.865a2.998 2.998 0 0 0 1.492 -2.397l.007 -.202v-1a3 3 0 0 1 3 -3z"/>
+      <path d="M5 10a1 1 0 0 1 .993 .883l.007 .117v9a1 1 0 0 1 -.883 .993l-.117 .007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-7a2 2 0 0 1 1.85 -1.995l.15 -.005h1z"/>
+    </symbol>
     <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--time">
       <path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/>
       <path d="M12 7v5l3 3"/>
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 7dc97a8f9..d68f0d7ce 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -2083,6 +2083,42 @@ 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;
+}
+.reaction-button.readonly {
+  cursor: default;
+}
+.reaction-button.readonly .icon-svg {
+  stroke: #999;
+}
+.reaction-button.readonly .icon-label {
+  color: #999;
+}
+div.issue.details .reaction {
+  float: right;
+  font-size: 0.9em;
+  margin-top: 0.5em;
+  clear: both;
+}
+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..8b26bee73 100644
--- a/app/controllers/messages_controller.rb
+++ b/app/controllers/messages_controller.rb
@@ -51,6 +51,8 @@ class MessagesController < ApplicationController
       offset(@reply_pages.offset).
       to_a
 
+    Message.preload_reaction_details(@replies)
+
     @reply = Message.new(:subject => "RE: #{@message.subject}")
     render :action => "show", :layout => false if request.xhr?
   end
diff --git a/app/controllers/news_controller.rb b/app/controllers/news_controller.rb
index 06240e359..dd6bade24 100644
--- a/app/controllers/news_controller.rb
+++ b/app/controllers/news_controller.rb
@@ -67,8 +67,10 @@ class NewsController < ApplicationController
   end
 
   def show
-    @comments = @news.comments.to_a
+    @comments = @news.comments.preload(:commented).to_a
     @comments.reverse! if User.current.wants_comments_in_reverse_order?
+
+    Comment.preload_reaction_details(@comments)
   end
 
   def new
diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb
new file mode 100644
index 000000000..f768f939d
--- /dev/null
+++ b/app/controllers/reactions_controller.rb
@@ -0,0 +1,65 @@
+# 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 :require_login
+
+  before_action :check_enabled
+  before_action :set_object, :authorize_reactable
+
+  def create
+    respond_to do |format|
+      format.js do
+        @object.reactions.find_or_create_by!(user: User.current)
+      end
+      format.any { head :not_found }
+    end
+  end
+
+  def destroy
+    respond_to do |format|
+      format.js do
+        reaction = @object.reactions.by(User.current).find_by(id: params[:id])
+        reaction&.destroy
+      end
+      format.any { head :not_found }
+    end
+  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 Redmine::Reaction.writable?(@object, 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..911da7127
--- /dev/null
+++ b/app/helpers/reactions_helper.rb
@@ -0,0 +1,100 @@
+# 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
+  # Maximum number of users to display in the reaction button tooltip
+  DISPLAY_REACTION_USERS_LIMIT = 10
+
+  def reaction_button(object)
+    return unless Redmine::Reaction.visible?(object, User.current)
+
+    detail = object.reaction_detail
+
+    reaction = detail.user_reaction
+    count = detail.reaction_count
+    visible_user_names = detail.visible_users.take(DISPLAY_REACTION_USERS_LIMIT).map(&:name)
+
+    tooltip = build_reaction_tooltip(visible_user_names, count)
+
+    if Redmine::Reaction.writable?(object, User.current)
+      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 readonly', 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(visible_user_names, count)
+    return if count.zero?
+
+    display_user_names = visible_user_names.dup
+    others = count - visible_user_names.size
+
+    if others.positive?
+      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..1716537af 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
@@ -36,6 +40,10 @@ class Comment < ApplicationRecord
     content
   end
 
+  def project
+    commented.respond_to?(:project) ? commented.project : nil
+  end
+
   private
 
   def send_notification
diff --git a/app/models/issue.rb b/app/models/issue.rb
index ac3b40bf1..bfef3533a 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).
+      to_a
 
     result.each_with_index {|j, i| j.indice = i + 1}
 
@@ -927,6 +929,9 @@ class Issue < ApplicationRecord
     end
     Journal.preload_journals_details_custom_fields(result)
     result.select! {|journal| journal.notes? || journal.visible_details.any?}
+
+    Journal.preload_reaction_details(result)
+
     result
   end
 
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..84c982043
--- /dev/null
+++ b/app/models/reaction.rb
@@ -0,0 +1,65 @@
+# 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) }
+  scope :for_reactable, ->(reactable) { where(reactable: reactable) }
+
+  # Represents reaction details for a reactable object
+  Detail = Struct.new(
+    # Total number of reactions
+    :reaction_count,
+    # Users who reacted and are visible to the target user
+    :visible_users,
+    # Reaction of the target user
+    :user_reaction
+  ) do
+    def initialize(reaction_count: 0, visible_users: [], user_reaction: nil)
+      super
+    end
+  end
+
+  def self.build_detail_map_for(reactables, user)
+    reactions = preload(:user)
+                  .for_reactable(reactables)
+                  .select(:id, :reactable_id, :user_id)
+                  .order(id: :desc)
+
+    # Prepare IDs of users who reacted and are visible to the user
+    visible_user_ids = User.visible(user)
+                         .joins(:reactions)
+                         .where(reactions: for_reactable(reactables))
+                         .pluck(:id).to_set
+
+    reactions.each_with_object({}) do |reaction, m|
+      m[reaction.reactable_id] ||= Detail.new
+
+      m[reaction.reactable_id].then do |detail|
+        detail.reaction_count += 1
+        detail.visible_users << reaction.user if visible_user_ids.include?(reaction.user.id)
+        detail.user_reaction = reaction if reaction.user == user
+      end
+    end
+  end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 4b6387ae5..9f74a60fb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -92,6 +92,7 @@ class User < Principal
   has_one :atom_token, lambda {where "#{table.name}.action='feeds'"}, :class_name => 'Token'
   has_one :api_token, lambda {where "#{table.name}.action='api'"}, :class_name => 'Token'
   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 36111efa4..eeac938b3 100644
--- a/app/views/issues/show.html.erb
+++ b/app/views/issues/show.html.erb
@@ -39,6 +39,10 @@
 
 <div class="subject">
 <%= render_issue_subject_with_tree(@issue) %>
+</div>
+
+<div class="reaction">
+  <%= reaction_button @issue %>
 </div>
         <p class="author">
         <%= 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 @@
 <h2><%= avatar(@topic.author) %><%= @topic.subject %></h2>
 
 <div class="message">
+<div class="reaction">
+  <%= reaction_button @topic %>
+</div>
 <p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
 <div id="message_topic_wiki" class="wiki">
 <%= textilizable(@topic, :content) %>
@@ -44,6 +47,7 @@
 <% @replies.each do |message| %>
   <div class="message reply" id="<%= "message-#{message.id}" %>">
     <div class="contextual">
+      <%= 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 @@
 </div>
 <% end %>
 
-<p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
-<span class="author"><%= authoring @news.created_on, @news.author %></span></p>
-<div class="wiki">
-<%= textilizable(@news, :description) %>
+<div class="news">
+  <div class="reaction">
+    <%= reaction_button @news %>
+  </div>
+  <p><% unless @news.summary.blank? %><em><%= @news.summary %></em><br /><% end %>
+  <span class="author"><%= authoring @news.created_on, @news.author %></span></p>
+  <div class="wiki">
+  <%= textilizable(@news, :description) %>
+  </div>
+  <%= link_to_attachments @news %>
 </div>
-<%= link_to_attachments @news %>
 <br />
 
 <div id="comments" style="margin-bottom:16px;">
@@ -38,6 +43,7 @@
 <% @comments.each do |comment| %>
     <% next if comment.new_record? %>
     <div class="contextual">
+    <%= 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..a5c923ea4
--- /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 %>').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..44206b6c2 100644
--- a/app/views/settings/_general.html.erb
+++ b/app/views/settings/_general.html.erb
@@ -37,6 +37,8 @@
 
 <p><%= setting_text_field :feeds_limit, :size => 6 %></p>
 
+<p><%= setting_check_box :reactions_enabled %></p>
+
 <%= call_hook(:view_settings_general_form) %>
 </div>
 
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..5f7291593 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,6 @@ 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_x_other_users:
+    one: "1 other"
+    other: "%{count} others"
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index d72d136ec..9bb11c884 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -1457,3 +1457,7 @@ ja:
   setting_related_issues_default_columns: 関連するチケットと子チケットの一覧で表示する項目
   setting_display_related_issues_table_headers: テーブルヘッダを表示
   error_can_not_remove_role_reason_members_html: "<p>以下のプロジェクトにこのロールのメンバーがいます:<br>%{projects}</p>"
+  setting_reactions_enabled: リアクション機能を有効にする
+  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..b6f2bf075
--- /dev/null
+++ b/lib/redmine/reaction.rb
@@ -0,0 +1,70 @@
+# 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)
+
+    # Returns true if the user can view the reaction information of the object
+    def self.visible?(object, user = User.current)
+      Setting.reactions_enabled? && object.visible?(user)
+    end
+
+    # Returns true if the user can add/remove a reaction to/from the object
+    def self.writable?(object, user = User.current)
+      user.logged? && visible?(object, user) && object&.project&.active?
+    end
+
+    module Reactable
+      extend ActiveSupport::Concern
+
+      included do
+        has_many :reactions, as: :reactable, dependent: :delete_all
+
+        attr_writer :reaction_detail
+      end
+
+      class_methods do
+        # Preloads reaction details for a collection of objects
+        def preload_reaction_details(objects)
+          return unless Setting.reactions_enabled?
+
+          details = ::Reaction.build_detail_map_for(objects, User.current)
+
+          objects.each do |object|
+            object.reaction_detail = details.fetch(object.id) { ::Reaction::Detail.new }
+          end
+        end
+      end
+
+      def reaction_detail
+        # Loads and returns reaction details if they are not already loaded.
+        # This is intended for cases where explicit preloading is unnecessary,
+        # such as retrieving reactions for a single issue on its detail page.
+        load_reaction_detail unless defined?(@reaction_detail)
+        @reaction_detail
+      end
+
+      def load_reaction_detail
+        self.class.preload_reaction_details([self])
+      end
+    end
+  end
+end
diff --git a/test/fixtures/reactions.yml b/test/fixtures/reactions.yml
new file mode 100644
index 000000000..d8fcbfc1b
--- /dev/null
+++ b/test/fixtures/reactions.yml
@@ -0,0 +1,51 @@
+---
+reaction_001:
+  id: 1
+  reactable_type: Issue
+  reactable_id: 1
+  user_id: 1
+reaction_002:
+  id: 2
+  reactable_type: Issue
+  reactable_id: 1
+  user_id: 2
+reaction_003:
+  id: 3
+  reactable_type: Issue
+  reactable_id: 1
+  user_id: 3
+reaction_004:
+  id: 4
+  reactable_type: Journal
+  reactable_id: 1
+  user_id: 2
+reaction_005:
+  id: 5
+  reactable_type: Issue
+  reactable_id: 6
+  user_id: 2
+reaction_006:
+  id: 6
+  reactable_type: Journal
+  reactable_id: 4
+  user_id: 2
+reaction_007:
+  id: 7
+  reactable_type: News
+  reactable_id: 1
+  user_id: 1
+reaction_008:
+  id: 8
+  reactable_type: Comment
+  reactable_id: 1
+  user_id: 2
+reaction_009:
+  id: 9
+  reactable_type: Message
+  reactable_id: 7
+  user_id: 2
+reaction_010:
+  id: 10
+  reactable_type: News
+  reactable_id: 3
+  user_id: 2
diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb
index b5180fcff..b7e0321d4 100644
--- a/test/functional/issues_controller_test.rb
+++ b/test/functional/issues_controller_test.rb
@@ -3331,6 +3331,42 @@ class IssuesControllerTest < Redmine::ControllerTest
     assert_select 'span.badge.badge-private', text: 'Private'
   end
 
+  def test_show_should_display_reactions
+    current_user = User.generate!
+
+    User.add_to_project(current_user, projects(:projects_001),
+      Role.generate!(users_visibility: 'members_of_visible_projects', permissions: [:view_issues]))
+
+    @request.session[:user_id] = current_user.id
+
+    get :show, params: { id: 1 }
+
+    assert_response :success
+
+    assert_select 'span[data-reaction-button-id=reaction_issue_1]' do
+      # The current_user can only see members who belong to projects that the current_user has access to.
+      # Since the Redmine Admin user does not belong to any projects visible to the current_user,
+      # the Redmine Admin user's name is not displayed in the reaction user list. Instead, "1 other" is shown.
+      assert_select 'a.reaction-button[title=?]', 'Dave Lopper, John Smith, and 1 other' do
+        assert_select 'span.icon-label', '3'
+      end
+    end
+
+    assert_select 'span[data-reaction-button-id=reaction_journal_1]' do
+      assert_select 'a.reaction-button[title=?]', 'John Smith'
+    end
+    assert_select 'span[data-reaction-button-id=reaction_journal_2] a.reaction-button'
+  end
+
+  def test_should_not_display_reactions_when_reactions_feature_is_disabled
+    with_settings reactions_enabled: '0' do
+      get :show, params: { id: 1 }
+
+      assert_response :success
+      assert_select 'span[data-reaction-button-id]', false
+    end
+  end
+
   def test_show_should_not_display_edit_attachment_icon_for_user_without_edit_issue_permission_on_tracker
     role = Role.find(2)
     role.set_permission_trackers 'edit_issues', [2, 3]
diff --git a/test/functional/messages_controller_test.rb b/test/functional/messages_controller_test.rb
index 74b9a3070..997b2263a 100644
--- a/test/functional/messages_controller_test.rb
+++ b/test/functional/messages_controller_test.rb
@@ -123,6 +123,27 @@ class MessagesControllerTest < Redmine::ControllerTest
     assert_select 'h3', {text: /Watchers \(\d*\)/, count: 0}
   end
 
+  def test_show_should_display_reactions
+    @request.session[:user_id] = 2
+
+    get :show, params: { board_id: 1, id: 4 }
+
+    assert_response :success
+    assert_select 'span[data-reaction-button-id=reaction_message_4] a.reaction-button' do
+      assert_select 'svg use[href*=thumb-up]'
+    end
+    assert_select 'span[data-reaction-button-id=reaction_message_5] a.reaction-button'
+    assert_select 'span[data-reaction-button-id=reaction_message_6] a.reaction-button'
+
+    # Should not display reactions when reactions feature is disabled.
+    with_settings reactions_enabled: '0' do
+      get :show, params: { board_id: 1, id: 4 }
+
+      assert_response :success
+      assert_select 'span[data-reaction-button-id]', false
+    end
+  end
+
   def test_get_new
     @request.session[:user_id] = 2
     get(:new, :params => {:board_id => 1})
diff --git a/test/functional/news_controller_test.rb b/test/functional/news_controller_test.rb
index f1ddfff71..536814c9d 100644
--- a/test/functional/news_controller_test.rb
+++ b/test/functional/news_controller_test.rb
@@ -106,6 +106,23 @@ class NewsControllerTest < Redmine::ControllerTest
     assert_response :not_found
   end
 
+  def test_show_should_display_reactions
+    @request.session[:user_id] = 1
+
+    get :show, params: { id: 1 }
+    assert_response :success
+    assert_select 'span[data-reaction-button-id=reaction_news_1] a.reaction-button.reacted'
+    assert_select 'span[data-reaction-button-id=reaction_comment_1] a.reaction-button'
+
+    # Should not display reactions when reactions feature is disabled.
+    with_settings reactions_enabled: '0' do
+      get :show, params: { id: 1 }
+
+      assert_response :success
+      assert_select 'span[data-reaction-button-id]', false
+    end
+  end
+
   def test_get_new_with_project_id
     @request.session[:user_id] = 2
     get(:new, :params => {:project_id => 1})
diff --git a/test/functional/reactions_controller_test.rb b/test/functional/reactions_controller_test.rb
new file mode 100644
index 000000000..b65794969
--- /dev/null
+++ b/test/functional/reactions_controller_test.rb
@@ -0,0 +1,394 @@
+# 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.
+
+require_relative '../test_helper'
+
+class ReactionsControllerTest < Redmine::ControllerTest
+  setup do
+    Setting.reactions_enabled = '1'
+    # jsmith
+    @request.session[:user_id] = users(:users_002).id
+  end
+
+  teardown do
+    Setting.clear_cache
+  end
+
+  test 'create for issue' do
+    issue = issues(:issues_002)
+
+    assert_difference(
+      ->{ Reaction.count } => 1,
+      ->{ issue.reactions.by(users(:users_002)).count } => 1
+    ) do
+      post :create, params: {
+        object_type: 'Issue',
+        object_id: issue.id
+      }, xhr: true
+    end
+
+    assert_response :success
+  end
+
+  test 'create for journal' do
+    journal = journals(:journals_005)
+
+    assert_difference(
+      ->{ Reaction.count } => 1,
+      ->{ journal.reactions.by(users(:users_002)).count } => 1
+    ) do
+      post :create, params: {
+        object_type: 'Journal',
+        object_id: journal.id
+      }, xhr: true
+    end
+
+    assert_response :success
+  end
+
+  test 'create for news' do
+    news = news(:news_002)
+
+    assert_difference(
+      ->{ Reaction.count } => 1,
+      ->{ news.reactions.by(users(:users_002)).count } => 1
+    ) do
+      post :create, params: {
+        object_type: 'News',
+        object_id: news.id
+      }, xhr: true
+    end
+
+    assert_response :success
+  end
+
+  test 'create reaction for comment' do
+    comment = comments(:comments_002)
+
+    assert_difference(
+      ->{ Reaction.count } => 1,
+      ->{ comment.reactions.by(users(:users_002)).count } => 1
+    ) do
+      post :create, params: {
+        object_type: 'Comment',
+        object_id: comment.id
+      }, xhr: true
+    end
+
+    assert_response :success
+  end
+
+  test 'create for message' do
+    message = messages(:messages_001)
+
+    assert_difference(
+      ->{ Reaction.count } => 1,
+      ->{ message.reactions.by(users(:users_002)).count } => 1
+    ) do
+      post :create, params: {
+        object_type: 'Message',
+        object_id: message.id
+      }, xhr: true
+    end
+
+    assert_response :success
+  end
+
+  test 'destroy for issue' do
+    reaction = reactions(:reaction_005)
+
+    assert_difference 'Reaction.count', -1 do
+      delete :destroy, params: {
+        id: reaction.id,
+        # Issue (id=6)
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :success
+    assert_not Reaction.exists?(reaction.id)
+  end
+
+  test 'destroy for journal' do
+    reaction = reactions(:reaction_006)
+
+    assert_difference 'Reaction.count', -1 do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :success
+    assert_not Reaction.exists?(reaction.id)
+  end
+
+  test 'destroy for news' do
+    # For News(id=3)
+    reaction = reactions(:reaction_010)
+
+    assert_difference 'Reaction.count', -1 do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :success
+    assert_not Reaction.exists?(reaction.id)
+  end
+
+  test 'destroy for comment' do
+    # For Comment(id=1)
+    reaction = reactions(:reaction_008)
+
+    assert_difference 'Reaction.count', -1 do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :success
+    assert_not Reaction.exists?(reaction.id)
+  end
+
+  test 'destroy for message' do
+    reaction = reactions(:reaction_009)
+
+    assert_difference 'Reaction.count', -1 do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :success
+    assert_not Reaction.exists?(reaction.id)
+  end
+
+  test 'create should respond with 403 when feature is disabled' do
+    Setting.reactions_enabled = '0'
+    # admin
+    @request.session[:user_id] = users(:users_001).id
+
+    assert_no_difference 'Reaction.count' do
+      post :create, params: {
+        object_type: 'Issue',
+        object_id: issues(:issues_002).id
+      }, xhr: true
+    end
+    assert_response :forbidden
+  end
+
+  test 'destroy should respond with 403 when feature is disabled' do
+    Setting.reactions_enabled = '0'
+    # admin
+    @request.session[:user_id] = users(:users_001).id
+
+    reaction = reactions(:reaction_001)
+    assert_no_difference 'Reaction.count' do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+    assert_response :forbidden
+  end
+
+  test 'create by anonymou user should respond with 401 when feature is disabled' do
+    Setting.reactions_enabled = '0'
+    @request.session[:user_id] = nil
+
+    assert_no_difference 'Reaction.count' do
+      post :create, params: {
+        object_type: 'Issue',
+        object_id: issues(:issues_002).id
+      }, xhr: true
+    end
+    assert_response :unauthorized
+  end
+
+  test 'create by anonymous user should respond with 401' do
+    @request.session[:user_id] = nil
+
+    assert_no_difference 'Reaction.count' do
+      post :create, params: {
+        object_type: 'Issue',
+        # Issue(id=1) is an issue in a public project
+        object_id: issues(:issues_001).id
+      }, xhr: true
+    end
+
+    assert_response :unauthorized
+  end
+
+  test 'destroy by anonymous user should respond with 401' do
+    @request.session[:user_id] = nil
+
+    reaction = reactions(:reaction_002)
+    assert_no_difference 'Reaction.count' do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :unauthorized
+  end
+
+  test 'create when reaction already exists should not create a new reaction and succeed' do
+    assert_no_difference 'Reaction.count' do
+      post :create, params: {
+        object_type: 'Comment',
+        # user(jsmith) has already reacted to Comment(id=1)
+        object_id: comments(:comments_001).id
+      }, xhr: true
+    end
+
+    assert_response :success
+  end
+
+  test 'destroy another user reaction should not destroy the reaction and succeed' do
+    # admin user's reaction
+    reaction = reactions(:reaction_001)
+
+    assert_no_difference 'Reaction.count' do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :success
+  end
+
+  test 'destroy nonexistent reaction' do
+    # For Journal(id=4)
+    reaction = reactions(:reaction_006)
+    reaction.destroy!
+
+    assert_not Reaction.exists?(reaction.id)
+
+    assert_no_difference 'Reaction.count' do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :success
+  end
+
+  test 'create with invalid object type should respond with 403' do
+    # admin
+    @request.session[:user_id] = users(:users_001).id
+
+    post :create, params: {
+      object_type: 'InvalidType',
+      object_id: 1
+    }, xhr: true
+
+    assert_response :forbidden
+  end
+
+  test 'create without permission to view should respond with 403' do
+    # dlopper
+    @request.session[:user_id] = users(:users_003).id
+
+    assert_no_difference 'Reaction.count' do
+      post :create, params: {
+        object_type: 'Issue',
+        # dlopper is not a member of the project where the issue (id=4) belongs.
+        object_id: issues(:issues_004).id
+      }, xhr: true
+    end
+
+    assert_response :forbidden
+  end
+
+  test 'destroy without permission to view should respond with 403' do
+    # dlopper
+    @request.session[:user_id] = users(:users_003).id
+
+    # For Issue(id=6)
+    reaction = reactions(:reaction_005)
+
+    assert_no_difference 'Reaction.count' do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :forbidden
+  end
+
+  test 'create should respond with 404 for non-JS requests' do
+    issue = issues(:issues_002)
+
+    assert_no_difference 'Reaction.count' do
+      post :create, params: {
+        object_type: 'Issue',
+        object_id: issue.id
+      } # Sending an HTML request by omitting xhr: true
+    end
+
+    assert_response :not_found
+  end
+
+  test 'create should respond with 403 when project is closed' do
+    issue = issues(:issues_010)
+    issue.project.update!(status: Project::STATUS_CLOSED)
+
+    assert_no_difference 'Reaction.count' do
+      post :create, params: {
+        object_type: 'Issue',
+        object_id: issue.id
+      }, xhr: true
+    end
+
+    assert_response :forbidden
+  end
+
+  test 'destroy should respond with 403 when project is closed' do
+    reaction = reactions(:reaction_005)
+    reaction.reactable.project.update!(status: Project::STATUS_CLOSED)
+
+    assert_no_difference 'Reaction.count' do
+      delete :destroy, params: {
+        id: reaction.id,
+        object_type: reaction.reactable_type,
+        object_id: reaction.reactable_id
+      }, xhr: true
+    end
+
+    assert_response :forbidden
+  end
+end
diff --git a/test/helpers/reactions_helper_test.rb b/test/helpers/reactions_helper_test.rb
new file mode 100644
index 000000000..f3a4e38d8
--- /dev/null
+++ b/test/helpers/reactions_helper_test.rb
@@ -0,0 +1,196 @@
+# 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.
+
+require_relative '../test_helper'
+
+class ReactionsHelperTest < ActionView::TestCase
+  include ReactionsHelper
+
+  setup do
+    User.current = users(:users_002)
+    Setting.reactions_enabled = '1'
+  end
+
+  teardown do
+    Setting.clear_cache
+  end
+
+  test 'reaction_id_for generates a DOM id' do
+    assert_equal "reaction_issue_1", reaction_id_for(issues(:issues_001))
+  end
+
+  test 'reaction_button returns nil when feature is disabled' do
+    Setting.reactions_enabled = '0'
+
+    assert_nil reaction_button(issues(:issues_004))
+  end
+
+  test 'reaction_button returns nil when object not visible' do
+    User.current = users(:users_003)
+
+    assert_nil reaction_button(issues(:issues_004))
+  end
+
+  test 'reaction_button for anonymous users shows readonly button' do
+    User.current = nil
+
+    result = reaction_button(journals(:journals_001))
+
+    assert_select_in result, 'span.reaction-button.readonly[title=?]', 'John Smith'
+    assert_select_in result, 'a.reaction-button', false
+  end
+
+  test 'reaction_button for inactive projects shows readonly button' do
+    issue6 = issues(:issues_006)
+    issue6.project.update!(status: Project::STATUS_CLOSED)
+
+    result = reaction_button(issue6)
+
+    assert_select_in result, 'span.reaction-button.readonly[title=?]', 'John Smith'
+    assert_select_in result, 'a.reaction-button', false
+  end
+
+  test 'reaction_button includes no tooltip when the object has no reactions' do
+    issue = issues(:issues_002) # Issue without reactions
+    result = reaction_button(issue)
+
+    assert_select_in result, 'a.reaction-button[title]', false
+  end
+
+  test 'reaction_button includes tooltip with all usernames when reactions are 10 or fewer' do
+    issue = issues(:issues_002)
+
+    reactions = build_reactions(10)
+    issue.reactions += reactions
+
+    result = with_locale 'en' do
+      reaction_button(issue)
+    end
+
+    # The tooltip should display usernames in order of newest reactions.
+    expected_tooltip = 'Bob9 Doe, Bob8 Doe, Bob7 Doe, Bob6 Doe, Bob5 Doe, ' \
+                       'Bob4 Doe, Bob3 Doe, Bob2 Doe, Bob1 Doe, and Bob0 Doe'
+
+    assert_select_in result, 'a.reaction-button[title=?]', expected_tooltip
+  end
+
+  test 'reaction_button includes tooltip with 10 usernames and others count when reactions exceed 10' do
+    issue = issues(:issues_002)
+
+    reactions = build_reactions(11)
+    issue.reactions += reactions
+
+    result = with_locale 'en' do
+      reaction_button(issue)
+    end
+
+    expected_tooltip = 'Bob10 Doe, Bob9 Doe, Bob8 Doe, Bob7 Doe, Bob6 Doe, ' \
+                       'Bob5 Doe, Bob4 Doe, Bob3 Doe, Bob2 Doe, Bob1 Doe, and 1 other'
+
+    assert_select_in result, 'a.reaction-button[title=?]', expected_tooltip
+  end
+
+  test 'reaction_button displays non-visible users as "X other" in the tooltip' do
+    issue2 = issues(:issues_002)
+
+    issue2.reaction_detail = Reaction::Detail.new(
+      # The remaining 3 users are non-visible users
+      reaction_count: 5,
+      visible_users: users(:users_002, :users_003)
+    )
+
+    result = with_locale('en') do
+      reaction_button(issue2)
+    end
+
+    assert_select_in result, 'a.reaction-button[title=?]', 'John Smith, Dave Lopper, and 3 others'
+
+    # When all users are non-visible users
+    issue2.reaction_detail = Reaction::Detail.new(
+      reaction_count: 2,
+      visible_users: []
+    )
+
+    result = with_locale('en') do
+      reaction_button(issue2)
+    end
+
+    assert_select_in result, 'a.reaction-button[title=?]', '2 others'
+  end
+
+  test 'reaction_button formats the tooltip content based on the support.array settings of each locale' do
+    result = with_locale('ja') do
+      reaction_button(issues(:issues_001))
+    end
+
+    assert_select_in result, 'a.reaction-button[title=?]', 'Dave Lopper、John Smith、Redmine Admin'
+  end
+
+  test 'reaction_button for reacted object' do
+    User.current = users(:users_002)
+
+    issue = issues(:issues_001)
+
+    result = with_locale('en') do
+      reaction_button(issue)
+    end
+    tooltip = 'Dave Lopper, John Smith, and Redmine Admin'
+
+    assert_select_in result, 'span[data-reaction-button-id=?]', 'reaction_issue_1' do
+      href = reaction_path(issue.reaction_detail.user_reaction, object_type: 'Issue', object_id: 1)
+
+      assert_select 'a.icon.reaction-button.reacted[href=?]', href do
+        assert_select 'use[href*=?]', 'thumb-up-filled'
+        assert_select 'span.icon-label', '3'
+      end
+
+      assert_select 'span.reaction-button', false
+    end
+  end
+
+  test 'reaction_button for non-reacted object' do
+    User.current = users(:users_004)
+
+    issue = issues(:issues_001)
+
+    result = with_locale('en') do
+      reaction_button(issue)
+    end
+    tooltip = 'Dave Lopper, John Smith, and Redmine Admin'
+
+    assert_select_in result, 'span[data-reaction-button-id=?]', 'reaction_issue_1' do
+      href = reactions_path(object_type: 'Issue', object_id: 1)
+
+      assert_select 'a.icon.reaction-button[href=?]', href do
+        assert_select 'use[href*=?]', 'thumb-up'
+        assert_select 'span.icon-label', '3'
+      end
+
+      assert_select 'span.reaction-button', false
+    end
+  end
+
+  private
+
+  def build_reactions(count)
+    Array.new(count) do |i|
+      Reaction.new(user: User.generate!(firstname: "Bob#{i}"))
+    end
+  end
+end
diff --git a/test/system/reactions_test.rb b/test/system/reactions_test.rb
new file mode 100644
index 000000000..01ba76832
--- /dev/null
+++ b/test/system/reactions_test.rb
@@ -0,0 +1,132 @@
+# 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.
+
+require_relative '../application_system_test_case'
+
+class ReactionsSystemTest < ApplicationSystemTestCase
+  def test_react_to_issue
+    log_user('jsmith', 'jsmith')
+
+    issue = issues(:issues_002)
+
+    with_settings(reactions_enabled: '1') do
+      visit '/issues/2'
+      reaction_button = find("div.issue.details [data-reaction-button-id=\"reaction_issue_#{issue.id}\"]")
+      assert_reaction_add_and_remove(reaction_button, issue)
+    end
+  end
+
+  def test_react_to_journal
+    log_user('jsmith', 'jsmith')
+
+    journal = journals(:journals_002)
+
+    with_settings(reactions_enabled: '1') do
+      visit '/issues/1'
+      reaction_button = find("[data-reaction-button-id=\"reaction_journal_#{journal.id}\"]")
+      assert_reaction_add_and_remove(reaction_button, journal.reload)
+    end
+  end
+
+  def test_react_to_forum_reply
+    log_user('jsmith', 'jsmith')
+
+    reply_message = messages(:messages_002) # reply to message_001
+
+    with_settings(reactions_enabled: '1') do
+      visit 'boards/1/topics/1'
+      reaction_button = find("[data-reaction-button-id=\"reaction_message_#{reply_message.id}\"]")
+      assert_reaction_add_and_remove(reaction_button, reply_message)
+    end
+  end
+
+  def test_react_to_forum_message
+    log_user('jsmith', 'jsmith')
+
+    message = messages(:messages_001)
+
+    with_settings(reactions_enabled: '1') do
+      visit 'boards/1/topics/1'
+      reaction_button = find("[data-reaction-button-id=\"reaction_message_#{message.id}\"]")
+      assert_reaction_add_and_remove(reaction_button, message)
+    end
+  end
+
+  def test_react_to_news
+    log_user('jsmith', 'jsmith')
+
+    with_settings(reactions_enabled: '1') do
+      visit '/news/2'
+      reaction_button = find("[data-reaction-button-id=\"reaction_news_2\"]")
+      assert_reaction_add_and_remove(reaction_button, news(:news_002))
+    end
+  end
+
+  def test_react_to_comment
+    log_user('jsmith', 'jsmith')
+
+    comment = comments(:comments_002)
+
+    with_settings(reactions_enabled: '1') do
+      visit '/news/1'
+      reaction_button = find("[data-reaction-button-id=\"reaction_comment_#{comment.id}\"]")
+      assert_reaction_add_and_remove(reaction_button, comment)
+    end
+  end
+
+  def test_reactions_disabled
+    log_user('jsmith', 'jsmith')
+
+    with_settings(reactions_enabled: '0') do
+      visit '/issues/1'
+      assert_no_selector('[data-reaction-button-id="reaction_issue_1"]')
+    end
+  end
+
+  def test_reaction_button_is_visible_but_not_clickable_for_not_logged_in_user
+    with_settings(reactions_enabled: '1') do
+      visit '/issues/1'
+
+      # visible
+      reaction_button = find('div.issue.details [data-reaction-button-id="reaction_issue_1"]')
+      within(reaction_button) { assert_selector('span.reaction-button') }
+      assert_equal "3", reaction_button.text
+
+      # not clickable
+      within(reaction_button) { assert_no_selector('a.reaction-button') }
+    end
+  end
+
+  private
+
+  def assert_reaction_add_and_remove(reaction_button, expected_subject)
+    # Add a reaction
+    within(reaction_button) { find('a.reaction-button').click }
+    find('body').hover # Hide tooltip
+    within(reaction_button) { assert_selector('a.reaction-button.reacted[title="John Smith"]') }
+    assert_equal "1", reaction_button.text
+    assert_equal 1, expected_subject.reactions.count
+
+    # Remove the reaction
+    within(reaction_button) { find('a.reacted').click }
+    within(reaction_button) { assert_selector('a.reaction-button:not(.reacted)') }
+    assert_equal "0", reaction_button.text
+    assert_equal 0, expected_subject.reactions.count
+  end
+end
diff --git a/test/unit/lib/redmine/reaction_test.rb b/test/unit/lib/redmine/reaction_test.rb
new file mode 100644
index 000000000..bed4600d0
--- /dev/null
+++ b/test/unit/lib/redmine/reaction_test.rb
@@ -0,0 +1,193 @@
+# 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.
+
+require_relative '../../../test_helper'
+
+class Redmine::ReactionTest < ActiveSupport::TestCase
+  setup do
+    @user = users(:users_002)
+    @issue = issues(:issues_007)
+    Setting.reactions_enabled = '1'
+  end
+
+  teardown do
+    Setting.clear_cache
+  end
+
+  test 'preload_reaction_details preloads ReactionDetail for all objects in the collection' do
+    User.current = users(:users_002)
+
+    issue1 = issues(:issues_001)
+    issue2 = issues(:issues_002)
+
+    assert_nil issue1.instance_variable_get(:@reaction_detail)
+    assert_nil issue2.instance_variable_get(:@reaction_detail)
+
+    Issue.preload_reaction_details([issue1, issue2])
+
+    expected_issue1_reaction_detail = Reaction::Detail.new(
+      reaction_count: 3,
+      visible_users: [users(:users_003), users(:users_002), users(:users_001)],
+      user_reaction: reactions(:reaction_002)
+    )
+
+    # ReactionDetail is already preloaded, so calling reaction_detail does not execute any query.
+    assert_no_queries do
+      assert_equal expected_issue1_reaction_detail, issue1.reaction_detail
+
+      # Even when an object has no reactions, an empty ReactionDetail is set.
+      assert_equal Reaction::Detail.new(
+        reaction_count: 0,
+        visible_users: [],
+        user_reaction: nil
+      ), issue2.reaction_detail
+    end
+  end
+
+  test 'visible_users in ReactionDetail preloaded by preload_reaction_details does not include non-visible users' do
+    current_user = User.current = User.generate!
+    visible_user = users(:users_002)
+    non_visible_user = User.generate!
+
+    project = Project.generate!
+    role = Role.generate!(users_visibility: 'members_of_visible_projects')
+
+    User.add_to_project(current_user, project, role)
+    User.add_to_project(visible_user, project, roles(:roles_001))
+
+    issue = Issue.generate!(project: project)
+
+    [current_user, visible_user, non_visible_user].each do |user|
+      issue.reactions.create!(user: user)
+    end
+
+    Issue.preload_reaction_details([issue])
+
+    # non_visible_user is not visible to current_user because they do not belong to any project.
+    assert_equal [visible_user, current_user], issue.reaction_detail.visible_users
+  end
+
+  test 'preload_reaction_details does nothing when the reaction feature is disabled' do
+    Setting.reactions_enabled = '0'
+
+    User.current = users(:users_002)
+    news1 = news(:news_001)
+
+    # Stub the Setting to avoid executing queries for retrieving settings,
+    # making it easier to confirm no queries are executed by preload_reaction_details().
+    Setting.stubs(:reactions_enabled?).returns(false)
+
+    assert_no_queries do
+      News.preload_reaction_details([news1])
+    end
+
+    assert_nil news1.instance_variable_get(:@reaction_detail)
+  end
+
+  test 'reaction_detail loads and returns ReactionDetail if it is not preloaded' do
+    message7 = messages(:messages_007)
+
+    User.current = users(:users_002)
+    assert_nil message7.instance_variable_get(:@reaction_detail)
+
+    assert_equal Reaction::Detail.new(
+      reaction_count: 1,
+      visible_users: [users(:users_002)],
+      user_reaction: reactions(:reaction_009)
+    ), message7.reaction_detail
+  end
+
+  test 'load_reaction_detail loads ReactionDetail for the object itself' do
+    comment1 = comments(:comments_001)
+
+    User.current = users(:users_001)
+    assert_nil comment1.instance_variable_get(:@reaction_detail)
+
+    comment1.load_reaction_detail
+
+    assert_equal Reaction::Detail.new(
+      reaction_count: 1,
+      visible_users: [users(:users_002)],
+      user_reaction: nil
+    ), comment1.reaction_detail
+  end
+
+  test 'visible? returns true when reactions are enabled and object is visible to user' do
+    object = issues(:issues_007)
+    user = users(:users_002)
+
+    assert Redmine::Reaction.visible?(object, user)
+  end
+
+  test 'visible? returns false when reactions are disabled' do
+    Setting.reactions_enabled = '0'
+
+    object = issues(:issues_007)
+    user = users(:users_002)
+
+    assert_not Redmine::Reaction.visible?(object, user)
+  end
+
+  test 'visible? returns false when object is not visible to user' do
+    object = issues(:issues_007)
+    user = users(:users_002)
+
+    object.expects(:visible?).with(user).returns(false)
+
+    assert_not Redmine::Reaction.visible?(object, user)
+  end
+
+  test 'writable? returns true for various reactable objects when user is logged in, object is visible, and project is active' do
+    reactable_objects = {
+      issue: issues(:issues_007),
+      message: messages(:messages_001),
+      news: news(:news_001),
+      journal: journals(:journals_001),
+      comment: comments(:comments_002)
+    }
+    user = users(:users_002)
+
+    reactable_objects.each do |type, object|
+      assert Redmine::Reaction.writable?(object, user), "Expected writable? to return true for #{type}"
+    end
+  end
+
+  test 'writable? returns false when user is not logged in' do
+    object = issues(:issues_007)
+    user = User.anonymous
+
+    assert_not Redmine::Reaction.writable?(object, user)
+  end
+
+  test 'writable? returns false when project is inactive' do
+    object = issues(:issues_007)
+    user = users(:users_002)
+    object.project.update!(status: Project::STATUS_ARCHIVED)
+
+    assert_not Redmine::Reaction.writable?(object, user)
+  end
+
+  test 'writable? returns false when project is closed' do
+    object = issues(:issues_007)
+    user = users(:users_002)
+    object.project.update!(status: Project::STATUS_CLOSED)
+
+    assert_not Redmine::Reaction.writable?(object, user)
+  end
+end
diff --git a/test/unit/reaction_test.rb b/test/unit/reaction_test.rb
new file mode 100644
index 000000000..2690da351
--- /dev/null
+++ b/test/unit/reaction_test.rb
@@ -0,0 +1,120 @@
+# 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.
+
+require_relative '../test_helper'
+
+class ReactionTest < ActiveSupport::TestCase
+  test 'validates :inclusion of reactable_type' do
+    %w(Issue Journal News Comment Message).each do |type|
+      reaction = Reaction.new(reactable_type: type, user: User.new)
+      assert reaction.valid?
+    end
+
+    assert_not Reaction.new(reactable_type: 'InvalidType', user: User.new).valid?
+  end
+
+  test 'scope: by' do
+    user2_reactions = issues(:issues_001).reactions.by(users(:users_002))
+
+    assert_equal [reactions(:reaction_002)], user2_reactions
+  end
+
+  test "should prevent duplicate reactions with unique constraint under concurrent creation" do
+    user = users(:users_001)
+    issue = issues(:issues_004)
+
+    threads = []
+    results = []
+
+    # Ensure both threads start at the same time
+    barrier = Concurrent::CyclicBarrier.new(2)
+
+    # Create two threads to simulate concurrent creation
+    2.times do
+      threads << Thread.new do
+        barrier.wait # Wait for both threads to be ready
+        begin
+          reaction = Reaction.create(
+            reactable: issue,
+            user: user
+          )
+          results << reaction.persisted?
+        rescue ActiveRecord::RecordNotUnique
+          results << false
+        end
+      end
+    end
+
+    # Wait for both threads to finish
+    threads.each(&:join)
+
+    # Ensure only one reaction was created
+    assert_equal 1, Reaction.where(reactable: issue, user: user).count
+    assert_includes results, true
+    assert_equal 1, results.count(true)
+  end
+
+  test 'build_detail_map_for generates a detail map for reactable objects' do
+    result = Reaction.build_detail_map_for([issues(:issues_001), issues(:issues_006)], users(:users_003))
+
+    expected = {
+      1 => Reaction::Detail.new(
+        reaction_count: 3,
+        visible_users: [users(:users_003), users(:users_002), users(:users_001)],
+        user_reaction: reactions(:reaction_003)
+      ),
+      6 => Reaction::Detail.new(
+        reaction_count: 1,
+        visible_users: [users(:users_002)],
+        user_reaction: nil
+      )
+    }
+    assert_equal expected, result
+
+    # When an object have no reactions, the result should be empty.
+    result = Reaction.build_detail_map_for([journals(:journals_002)], users(:users_002))
+
+    assert_empty result
+  end
+
+  test 'build_detail_map_for filters users based on visibility' do
+    current_user = User.generate!
+    visible_user = users(:users_002)
+    non_visible_user = User.generate!
+
+    project = Project.generate!
+    role = Role.generate!(users_visibility: 'members_of_visible_projects')
+
+    User.add_to_project(current_user, project, role)
+    User.add_to_project(visible_user, project, roles(:roles_001))
+
+    issue = Issue.generate!(project: project)
+
+    [current_user, visible_user, non_visible_user].each do |user|
+      issue.reactions.create!(user: user)
+    end
+
+    result = Reaction.build_detail_map_for([issue], current_user)
+
+    assert_equal(
+      [current_user, visible_user].sort_by(&:id),
+      result[issue.id].visible_users.sort_by(&:id)
+    )
+  end
+end
diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb
index ede12e1ce..aeae62df8 100644
--- a/test/unit/user_test.rb
+++ b/test/unit/user_test.rb
@@ -1376,4 +1376,16 @@ class UserTest < ActiveSupport::TestCase
       User.prune(7)
     end
   end
+
+  def test_destroy_should_delete_associated_reactions
+    users(:users_004).reactions.create!(
+      [
+        {reactable: issues(:issues_001)},
+        {reactable: issues(:issues_002)}
+      ]
+    )
+    assert_difference 'Reaction.count', -2 do
+      users(:users_004).destroy
+    end
+  end
 end
