discourse/plugins/discourse-reactions/plugin.rb
Martin Brennan 2075f61abb
FEATURE: Promote discourse_reactions_allow_any_emoji out of experimental (#35589)
Followup f002aa4022

We are renaming `discourse_reactions_experimental_allow_any_emoji` to
`discourse_reactions_allow_any_emoji`
and moving it out of the Experimental site setting area.

We originally kept this setting experimental because we thought
there might besome moderation concerts around allowing any emoji to be
used.
However, we later realised the `emoji_deny_list` core site setting can
be used to effectively restrict which emoji are allowed to be used here.

The setting `discourse_reactions_allow_any_emoji` remains off by
default.
I also added some of the discourse-reactions settings to the `emojis`
site setting area to make them easier to find.
2025-10-24 16:51:18 +10:00

444 lines
16 KiB
Ruby
Vendored

# 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
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: "/" }
add_to_serializer(:post, :reactions) do
reactions = []
reaction_users_counting_as_like = Set.new
object
.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 =
object.post_actions.reject do |post_action|
# 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.
next true if reaction_users_counting_as_like.include?(post_action.user_id)
# Also get rid of any PostAction records that match up to a ReactionUser
# that is now the main_reaction_id and has historical data.
object
.post_actions_with_reaction_users
&.dig(post_action.id)
&.reaction_user
&.reaction
&.reaction_value == DiscourseReactions::Reaction.main_reaction_id
end
# 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.blank?
# 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.size + reaction_likes.sum { |r| r[:count] },
}
reactions.sort_by { |reaction| [-reaction[:count].to_i, reaction[:id]] }
end
add_to_serializer(:post, :current_user_reaction) do
return nil if scope.is_anonymous?
object.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 =
object.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
add_to_serializer(:post, :reaction_users_count) do
return object.reaction_users_count unless object.reaction_users_count.nil?
TopicViewSerializer.posts_reaction_users_count(object.id)[object.id]
end
add_to_serializer(:post, :current_user_used_main_reaction) do
return false if scope.is_anonymous?
like_post_action =
object.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 =
object.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
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,
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",
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