mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-25 12:55:00 +08:00
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.
444 lines
16 KiB
Ruby
Vendored
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
|