discourse/plugins/discourse-reactions/plugin.rb
David Battersby 4980c4148e
FIX: prevent shadow likes leaking to post reaction modal (#39373)
Hardens the discourse-reactions plugin so shadow likes don't leak as
duplicate rows.
2026-04-21 11:50:40 +04:00

649 lines
23 KiB
Ruby

# frozen_string_literal: true
# name: discourse-reactions
# about: Allows users to react to a post with emojis.
# meta_topic_id: 183261
# version: 0.5
# author: Ahmed Gagan, Rafael dos Santos Silva, Kris Aubuchon, Joffrey Jaffeux, Kris Kotlarek, Jordan Vidrine
# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-reactions
enabled_site_setting :discourse_reactions_enabled
register_asset "stylesheets/common/discourse-reactions.scss"
register_asset "stylesheets/desktop/discourse-reactions.scss", :desktop
register_asset "stylesheets/mobile/discourse-reactions.scss", :mobile
register_svg_icon "star"
register_svg_icon "far-star"
require_relative "lib/reaction_for_like_site_setting_enum"
require_relative "lib/reactions_excluded_from_like_site_setting_validator"
module ::DiscourseReactions
PLUGIN_NAME = "discourse-reactions"
end
require_relative "lib/discourse_reactions/engine"
after_initialize do
SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-reactions", "db", "fixtures").to_s
%w[
app/controllers/discourse_reactions/custom_reactions_controller.rb
app/models/discourse_reactions/reaction_user.rb
app/models/discourse_reactions/reaction.rb
app/serializers/reaction_serializer.rb
app/serializers/user_reaction_serializer.rb
app/services/discourse_reactions/reaction_manager.rb
app/services/discourse_reactions/reaction_notification.rb
app/services/discourse_reactions/reaction_like_synchronizer.rb
lib/discourse_reactions/guardian_extension.rb
lib/discourse_reactions/notification_extension.rb
lib/discourse_reactions/post_alerter_extension.rb
lib/discourse_reactions/post_extension.rb
lib/discourse_reactions/post_action_extension.rb
lib/discourse_reactions/posts_reaction_loader.rb
lib/discourse_reactions/topic_view_serializer_extension.rb
lib/discourse_reactions/topic_view_posts_serializer_extension.rb
lib/discourse_reactions/migration_report.rb
lib/discourse_reactions/post_reactions_query.rb
app/jobs/regular/discourse_reactions/like_synchronizer.rb
app/jobs/scheduled/discourse_reactions/scheduled_like_synchronizer.rb
].each { |path| require_relative path }
reloadable_patch do |plugin|
Post.prepend DiscourseReactions::PostExtension
PostAction.prepend DiscourseReactions::PostActionExtension
TopicViewSerializer.prepend DiscourseReactions::TopicViewSerializerExtension
TopicViewPostsSerializer.prepend DiscourseReactions::TopicViewPostsSerializerExtension
PostAlerter.prepend DiscourseReactions::PostAlerterExtension
Guardian.prepend DiscourseReactions::GuardianExtension
Notification.singleton_class.prepend DiscourseReactions::NotificationExtension
end
Discourse::Application.routes.append { mount DiscourseReactions::Engine, at: "/" }
TopicView.on_preload do |topic_view|
next unless SiteSetting.discourse_reactions_enabled
posts = topic_view.posts
next if posts.blank?
ActiveRecord::Associations::Preloader.new(
records: posts,
associations: [:post_actions, { reactions: { reaction_users: :user } }],
).call
post_ids = posts.map(&:id).uniq
reaction_users_count_map = TopicViewSerializer.posts_reaction_users_count(post_ids)
post_actions_with_reaction_users =
DiscourseReactions::TopicViewSerializerExtension.load_post_action_reaction_users_for_posts(
post_ids,
)
main_reaction = DiscourseReactions::Reaction.main_reaction_id
excluded = DiscourseReactions::Reaction.reactions_excluded_from_like
excluded_filter =
if excluded.present?
"AND dr.reaction_value NOT IN (:excluded)"
else
""
end
sql_params = {
post_ids: post_ids,
like_type: PostActionType::LIKE_POST_ACTION_ID,
main_reaction: main_reaction,
}
sql_params[:excluded] = excluded if excluded.present?
likes_rows = DB.query(<<~SQL, **sql_params)
SELECT pa.post_id, COUNT(*) as likes_count
FROM post_actions pa
WHERE pa.deleted_at IS NULL
AND pa.post_id IN (:post_ids)
AND pa.post_action_type_id = :like_type
AND NOT EXISTS (
SELECT 1 FROM discourse_reactions_reaction_users dru
JOIN discourse_reactions_reactions dr ON dr.id = dru.reaction_id
WHERE dr.post_id = pa.post_id
AND dru.user_id = pa.user_id
AND dr.reaction_value != :main_reaction
#{excluded_filter}
)
AND NOT EXISTS (
SELECT 1 FROM discourse_reactions_reaction_users dru
JOIN discourse_reactions_reactions dr ON dr.id = dru.reaction_id
WHERE dr.post_id = pa.post_id
AND dru.user_id = pa.user_id
AND dr.reaction_value = :main_reaction
)
GROUP BY pa.post_id
SQL
likes_map = likes_rows.each_with_object({}) { |row, h| h[row.post_id] = row.likes_count }
precomputed_reactions_map = {}
posts.each do |post|
post.reaction_users_count = reaction_users_count_map[post.id].to_i
post.post_actions_with_reaction_users = post_actions_with_reaction_users[post.id] || {}
emoji_reactions = post.emoji_reactions.select { |r| r.reaction_users_count.to_i > 0 }
reactions =
emoji_reactions.map do |reaction|
{
id: reaction.reaction_value,
type: reaction.reaction_type.to_sym,
count: reaction.reaction_users_count,
}
end
likes = likes_map[post.id] || 0
if likes > 0
reaction_likes, reactions = reactions.partition { |r| r[:id] == main_reaction }
reactions << {
id: main_reaction,
type: :emoji,
count: likes + reaction_likes.sum { |r| r[:count] },
}
end
precomputed_reactions_map[post.id] = reactions.sort_by { |r| [-r[:count].to_i, r[:id]] }
end
topic_view.set_preloaded_post_data(:reactions, precomputed_reactions_map)
topic_view.set_preloaded_post_data(:reaction_users_count, reaction_users_count_map)
end
# Helper module for shared reactions serialization logic
module ReactionsSerializerHelpers
def self.reactions_for_post(post)
reactions = []
reaction_users_counting_as_like = Set.new
post
.emoji_reactions
.select { |reaction| reaction[:reaction_users_count] }
.each do |reaction|
reactions << {
id: reaction.reaction_value,
type: reaction.reaction_type.to_sym,
count: reaction.reaction_users_count,
}
# NOTE: It does not matter if the reaction is currently an enabled one,
# we need to handle historical data here too so we don't see double-ups in the UI.
if !DiscourseReactions::Reaction.reactions_excluded_from_like.include?(
reaction.reaction_value,
) && reaction.reaction_value != DiscourseReactions::Reaction.main_reaction_id
reaction_users_counting_as_like.merge(reaction.reaction_users.pluck(:user_id))
end
end
likes_query =
post.post_actions.where(post_action_type_id: PostActionType::LIKE_POST_ACTION_ID)
# Get rid of any PostAction records that match up to a ReactionUser
# that is NOT main_reaction_id and is NOT excluded, otherwise we double
# up on the count/reaction shown in the UI.
if reaction_users_counting_as_like.any?
likes_query = likes_query.where.not(user_id: reaction_users_counting_as_like.to_a)
end
# Also get rid of any PostAction records that match up to a ReactionUser
# that is now the main_reaction_id and has historical data.
# This subquery checks if there's a matching ReactionUser with main_reaction_id.
likes_query =
likes_query.where(
<<~SQL,
post_actions.id NOT IN (
SELECT post_actions.id
FROM post_actions
INNER JOIN discourse_reactions_reaction_users
ON discourse_reactions_reaction_users.post_id = post_actions.post_id
AND discourse_reactions_reaction_users.user_id = post_actions.user_id
INNER JOIN discourse_reactions_reactions
ON discourse_reactions_reactions.id = discourse_reactions_reaction_users.reaction_id
WHERE post_actions.post_id = :post_id
AND post_actions.post_action_type_id = :like_type
AND discourse_reactions_reactions.reaction_value = :main_reaction
)
SQL
post_id: post.id,
like_type: PostActionType::LIKE_POST_ACTION_ID,
main_reaction: DiscourseReactions::Reaction.main_reaction_id,
)
likes = likes_query.count
# Likes will only be blank if there are only reactions where the reaction is in
# discourse_reactions_excluded_from_like. All other reactions will have a `PostAction` record.
return reactions.sort_by { |reaction| [-reaction[:count].to_i, reaction[:id]] } if likes.zero?
# Reactions using main_reaction_id only have a `PostAction` record,
# not any `ReactionUser` records, as long as the main_reaction_id was never
# changed -- if it was then we could have a ReactionUser as well.
reaction_likes, reactions =
reactions.partition { |r| r[:id] == DiscourseReactions::Reaction.main_reaction_id }
reactions << {
id: DiscourseReactions::Reaction.main_reaction_id,
type: :emoji,
count: likes + reaction_likes.sum { |r| r[:count] },
}
reactions.sort_by { |reaction| [-reaction[:count].to_i, reaction[:id]] }
end
def self.current_user_reaction_for_post(post, scope)
return nil if scope.is_anonymous?
post.emoji_reactions.each do |reaction|
reaction_user = reaction.reaction_users.find { |ru| ru.user_id == scope.user.id }
next if reaction_user.blank?
if reaction.reaction_users_count
return(
{
id: reaction.reaction_value,
type: reaction.reaction_type.to_sym,
can_undo: reaction_user.can_undo?,
}
)
end
end
# Any PostAction Like that doesn't have a matching ReactionUser record
# will count as the main_reaction_id.
like =
post.post_actions.find do |post_action|
post_action.post_action_type_id == PostActionType::LIKE_POST_ACTION_ID &&
!post_action.trashed? && post_action.user_id == scope.user.id
end
return nil if like.blank?
{
id: DiscourseReactions::Reaction.main_reaction_id,
type: :emoji,
can_undo: scope.can_delete_post_action?(like),
}
end
def self.reaction_users_count_for_post(post)
return post.reaction_users_count unless post.reaction_users_count.nil?
TopicViewSerializer.posts_reaction_users_count(post.id)[post.id]
end
def self.current_user_used_main_reaction_for_post(post, scope)
return false if scope.is_anonymous?
like_post_action =
post.post_actions.find do |post_action|
post_action.post_action_type_id == PostActionType::LIKE_POST_ACTION_ID &&
post_action.user_id == scope.user.id && !post_action.trashed?
end
has_matching_reaction_user =
post.emoji_reactions.any? do |reaction|
reaction.reaction_users.any? { |ru| ru.user_id == scope.user.id } &&
(
if SiteSetting.discourse_reactions_allow_any_emoji
reaction.reaction_value != DiscourseReactions::Reaction.main_reaction_id
else
DiscourseReactions::Reaction.reactions_counting_as_like.include?(
reaction.reaction_value,
)
end
)
end
like_post_action.present? && !has_matching_reaction_user
end
def self.op_reactions_data_for_topic(topic, scope)
return nil unless topic.first_post
post = topic.first_post
like_action =
(
if scope.user
PostAction.find_by(
user_id: scope.user.id,
post_id: post.id,
post_action_type_id: PostActionType.types[:like],
)
else
nil
end
)
{
id: post.id,
user_id: post.user_id,
yours: post.user_id == scope.current_user&.id,
reactions: reactions_for_post(post),
current_user_reaction: current_user_reaction_for_post(post, scope),
current_user_used_main_reaction: current_user_used_main_reaction_for_post(post, scope),
reaction_users_count: reaction_users_count_for_post(post) || 0,
likeAction: {
canToggle: like_action ? scope.can_delete_post_action?(like_action) : true,
},
}
end
end
add_to_serializer(:post, :reactions) do
map = topic_view&.preloaded_post_data(:reactions)
if map && map.key?(object.id)
map[object.id]
else
ReactionsSerializerHelpers.reactions_for_post(object)
end
end
add_to_serializer(:post, :current_user_reaction) do
ReactionsSerializerHelpers.current_user_reaction_for_post(object, scope)
end
add_to_serializer(:post, :reaction_users_count) do
map = topic_view&.preloaded_post_data(:reaction_users_count)
if map && map.key?(object.id)
map[object.id].to_i
else
ReactionsSerializerHelpers.reaction_users_count_for_post(object)
end
end
add_to_serializer(:post, :current_user_used_main_reaction) do
ReactionsSerializerHelpers.current_user_used_main_reaction_for_post(object, scope)
end
add_to_serializer(
:topic_list_item,
:op_reactions_data,
include_condition: -> do
object.association(:first_post).loaded? &&
DiscoursePluginRegistry.apply_modifier(
:include_discourse_reactions_data_on_topic_list,
false,
scope.user,
)
end,
) { ReactionsSerializerHelpers.op_reactions_data_for_topic(object, scope) }
add_to_serializer(
:suggested_topic,
:op_reactions_data,
include_condition: -> do
object.association(:first_post).loaded? &&
DiscoursePluginRegistry.apply_modifier(
:include_discourse_reactions_data_on_suggested_topics,
false,
scope.user,
)
end,
) { ReactionsSerializerHelpers.op_reactions_data_for_topic(object, scope) }
add_to_serializer(:topic_view, :valid_reactions) { DiscourseReactions::Reaction.valid_reactions }
add_model_callback(User, :before_destroy) do
DiscourseReactions::ReactionUser.where(user_id: self.id).delete_all
end
add_report("reactions") do |report|
main_id = DiscourseReactions::Reaction.main_reaction_id
report.icon = "discourse-emojis"
report.modes = [:table]
report.data = []
report.labels = [
{ type: :date, property: :day, title: I18n.t("reports.reactions.labels.day") },
{
type: :number,
property: :like_count,
title: main_id,
html_title: PrettyText.unescape_emoji(CGI.escapeHTML(":#{main_id}:")),
},
]
reactions = SiteSetting.discourse_reactions_enabled_reactions.split("|") - [main_id]
reactions.each do |reaction|
report.labels << {
type: :number,
property: "#{reaction}_count",
title: reaction,
html_title: PrettyText.unescape_emoji(CGI.escapeHTML(":#{reaction}:")),
}
end
reactions_results =
DB.query(<<~SQL, start_date: report.start_date.to_date, end_date: report.end_date.to_date)
SELECT
reactions.reaction_value,
count(reaction_users.id) as reactions_count,
date_trunc('day', reaction_users.created_at)::date as day
FROM discourse_reactions_reactions as reactions
LEFT OUTER JOIN discourse_reactions_reaction_users as reaction_users on reactions.id = reaction_users.reaction_id
WHERE reactions.reaction_users_count IS NOT NULL
AND reaction_users.created_at::DATE >= :start_date::DATE AND reaction_users.created_at::DATE <= :end_date::DATE
GROUP BY reactions.reaction_value, day
SQL
likes_results =
DB.query(
<<~SQL,
SELECT
count(post_actions.id) as likes_count,
date_trunc('day', post_actions.created_at)::date as day
FROM post_actions as post_actions
WHERE post_actions.created_at::DATE >= :start_date::DATE AND post_actions.created_at::DATE <= :end_date::DATE
AND #{DiscourseReactions::PostActionExtension.filter_reaction_likes_sql}
GROUP BY day
SQL
start_date: report.start_date.to_date,
end_date: report.end_date.to_date,
like: PostActionType::LIKE_POST_ACTION_ID,
valid_reactions: DiscourseReactions::Reaction.valid_reactions.to_a,
)
(report.start_date.to_date..report.end_date.to_date).each do |date|
data = { day: date }
like_count = 0
like_reaction_count = 0
likes_results.select { |r| r.day == date }.each { |result| like_count += result.likes_count }
reactions_results
.select { |r| r.day == date }
.each do |result|
if result.reaction_value == main_id
like_reaction_count += result.reactions_count
else
data["#{result.reaction_value}_count"] ||= 0
data["#{result.reaction_value}_count"] += result.reactions_count
end
end
data[:like_count] = like_reaction_count + like_count
report.data << data
end
end
field_key = "display_username"
consolidated_reactions =
Notifications::ConsolidateNotifications
.new(
from: Notification.types[:reaction],
to: Notification.types[:reaction],
threshold: -> { SiteSetting.notification_consolidation_threshold },
consolidation_window: SiteSetting.likes_notification_consolidation_window_mins.minutes,
unconsolidated_query_blk:
Proc.new do |notifications, data|
notifications.where(
"data::json ->> 'username2' IS NULL AND data::json ->> 'consolidated' IS NULL",
).where("data::json ->> '#{field_key}' = ?", data[field_key.to_sym].to_s)
end,
consolidated_query_blk:
Proc.new do |notifications, data|
notifications.where("(data::json ->> 'consolidated')::bool").where(
"data::json ->> '#{field_key}' = ?",
data[field_key.to_sym].to_s,
)
end,
)
.set_mutations(
set_data_blk:
Proc.new do |notification|
data = notification.data_hash
data.merge(
username: data[:display_username],
name: data[:display_name],
consolidated: true,
)
end,
)
.set_precondition(precondition_blk: Proc.new { |data| data[:username2].blank? })
consolidated_reactions.before_consolidation_callbacks(
before_consolidation_blk:
Proc.new do |notifications, data|
new_icon = data[:reaction_icon]
if new_icon
icons = notifications.pluck("data::json ->> 'reaction_icon'")
data.delete(:reaction_icon) if icons.any? { |i| i != new_icon }
end
end,
before_update_blk:
Proc.new do |consolidated, updated_data, notification|
if consolidated.data_hash[:reaction_icon] != notification.data_hash[:reaction_icon]
updated_data.delete(:reaction_icon)
end
end,
)
reacted_by_two_users =
Notifications::DeletePreviousNotifications
.new(
type: Notification.types[:reaction],
previous_query_blk:
Proc.new do |notifications, data|
notifications.where(id: data[:previous_notification_id])
end,
)
.set_mutations(
set_data_blk:
Proc.new do |notification|
existing_notification_of_same_type =
Notification
.where(user: notification.user)
.order("notifications.id DESC")
.where(topic_id: notification.topic_id, post_number: notification.post_number)
.where(notification_type: notification.notification_type)
.where("created_at > ?", 1.day.ago)
.first
data = notification.data_hash
if existing_notification_of_same_type
same_type_data = existing_notification_of_same_type.data_hash
new_data =
data.merge(
previous_notification_id: existing_notification_of_same_type.id,
username2: same_type_data[:display_username],
name2: same_type_data[:display_name],
count: (same_type_data[:count] || 1).to_i + 1,
)
new_data
else
data
end
end,
)
.set_precondition(
precondition_blk:
Proc.new do |data, notification|
always_freq = UserOption.like_notification_frequency_type[:always]
notification.user&.user_option&.like_notification_frequency == always_freq &&
data[:previous_notification_id].present?
end,
)
register_notification_consolidation_plan(reacted_by_two_users)
register_notification_consolidation_plan(consolidated_reactions)
# Filter out Likes that are also Reactions, for the user likes-received page.
register_modifier(:user_action_stream_builder) do |builder|
builder.left_join(<<~SQL)
discourse_reactions_reaction_users ON discourse_reactions_reaction_users.post_id = a.target_post_id
AND discourse_reactions_reaction_users.user_id = a.acting_user_id
SQL
builder.where("discourse_reactions_reaction_users.id IS NULL")
end
# Filter out the users who Liked as well as Reacted to the post, for the
# user avatars that show beneath the post when you click the "show more actions"
# [...] button.
register_modifier(:post_action_users_list) do |query, post|
where_clause = <<~SQL
post_actions.id NOT IN (
SELECT post_actions.id
FROM post_actions
INNER JOIN discourse_reactions_reaction_users ON discourse_reactions_reaction_users.post_id = post_actions.post_id
AND discourse_reactions_reaction_users.user_id = post_actions.user_id
WHERE post_actions.post_id = #{post.id}
)
SQL
query.where(where_clause)
end
on(:first_post_moved) do |target_post, original_post|
id_map = {}
ActiveRecord::Base.transaction do
reactions = DiscourseReactions::Reaction.where(post_id: original_post.id)
next if !reactions.any?
reactions_attributes =
reactions.map { |reaction| reaction.attributes.except("id").merge(post_id: target_post.id) }
DiscourseReactions::Reaction
.insert_all(reactions_attributes)
.each_with_index { |entry, index| id_map[reactions[index].id] = entry["id"] }
reaction_users = DiscourseReactions::ReactionUser.where(post_id: original_post.id)
next if !reaction_users.any?
reaction_users_attributes =
reaction_users.map do |reaction_user|
reaction_user
.attributes
.except("id")
.merge(post_id: target_post.id, reaction_id: id_map[reaction_user.reaction_id])
end
DiscourseReactions::ReactionUser.insert_all(reaction_users_attributes)
end
end
on(:site_setting_changed) do |name, old_value, new_value|
if name == :discourse_reactions_excluded_from_like &&
SiteSetting.discourse_reactions_like_sync_enabled
::Jobs.cancel_scheduled_job(Jobs::DiscourseReactions::LikeSynchronizer)
::Jobs.enqueue_at(5.minutes.from_now, Jobs::DiscourseReactions::LikeSynchronizer)
end
end
end