diff --git a/.github/labeler.yml b/.github/labeler.yml index fcb81b14747..19178ddf1a2 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -26,6 +26,10 @@ discourse-presence: - changed-files: - any-glob-to-any-file: plugins/discourse-presence/**/* +discourse-reactions: + - changed-files: + - any-glob-to-any-file: plugins/discourse-reactions/**/* + footnote: - changed-files: - any-glob-to-any-file: plugins/footnote/**/* diff --git a/.gitignore b/.gitignore index 8a1ff6d189e..e4a67bece50 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ !/plugins/spoiler-alert !/plugins/checklist !/plugins/footnote +!/plugins/discourse-reactions /plugins/*/auto_generated /spec/fixtures/plugins/my_plugin/auto_generated diff --git a/plugins/discourse-reactions/README.md b/plugins/discourse-reactions/README.md new file mode 100644 index 00000000000..96fc9237c19 --- /dev/null +++ b/plugins/discourse-reactions/README.md @@ -0,0 +1,5 @@ +# Discourse Reactions + +Discourse-reactions is a plugin that allows user to add their reactions to the post. + +For more information, please see: https://meta.discourse.org/t/discourse-reactions-beyond-likes/183261 diff --git a/plugins/discourse-reactions/app/controllers/discourse_reactions/custom_reactions_controller.rb b/plugins/discourse-reactions/app/controllers/discourse_reactions/custom_reactions_controller.rb new file mode 100644 index 00000000000..45cfc086cc2 --- /dev/null +++ b/plugins/discourse-reactions/app/controllers/discourse_reactions/custom_reactions_controller.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +class DiscourseReactions::CustomReactionsController < ApplicationController + MAX_USERS_COUNT = 26 + + requires_plugin DiscourseReactions::PLUGIN_NAME + + before_action :ensure_logged_in, except: [:post_reactions_users] + + def toggle + post = fetch_post_from_params + + if DiscourseReactions::Reaction.valid_reactions.exclude?(params[:reaction]) + return render_json_error(post) + end + + begin + manager = + DiscourseReactions::ReactionManager.new( + reaction_value: params[:reaction], + user: current_user, + post: post, + ) + manager.toggle! + rescue ActiveRecord::RecordNotUnique + # If the user already performed this action, it's probably due to a different browser tab + # or non-debounced clicking. We can ignore. + end + + post.publish_change_to_clients!(:acted) + publish_change_to_clients!( + post, + reaction: manager.reaction_value, + previous_reaction: manager.previous_reaction_value, + ) + + render_json_dump(post_serializer(post).as_json) + end + + def reactions_given + params.require(:username) + user = + fetch_user_from_params( + include_inactive: + current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts), + ) + raise Discourse::NotFound unless guardian.can_see_profile?(user) + + reaction_users = + DiscourseReactions::ReactionUser + .joins( + "INNER JOIN discourse_reactions_reactions ON discourse_reactions_reactions.id = discourse_reactions_reaction_users.reaction_id", + ) + .joins( + "INNER JOIN posts p ON p.id = discourse_reactions_reaction_users.post_id AND p.deleted_at IS NULL", + ) + .joins("INNER JOIN topics t ON t.id = p.topic_id AND t.deleted_at IS NULL") + .joins( + "INNER JOIN posts p2 ON p2.topic_id = t.id AND p2.post_number = 1 AND p.deleted_at IS NULL", + ) + .joins("LEFT JOIN categories c ON c.id = t.category_id") + .includes(:user, :post, :reaction) + .where(user_id: user.id) + .where("discourse_reactions_reactions.reaction_users_count IS NOT NULL") + + reaction_users = secure_reaction_users!(reaction_users) + + if params[:before_reaction_user_id] + reaction_users = + reaction_users.where( + "discourse_reactions_reaction_users.id < ?", + params[:before_reaction_user_id].to_i, + ) + end + + reaction_users = reaction_users.order(created_at: :desc).limit(20) + + render_serialized(reaction_users.to_a, UserReactionSerializer) + end + + def reactions_received + params.require(:username) + user = + fetch_user_from_params( + include_inactive: + current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts), + ) + raise Discourse::InvalidAccess unless guardian.can_see_notifications?(user) + + posts = Post.joins(:topic).where(user_id: user.id) + posts = guardian.filter_allowed_categories(posts) + post_ids = posts.select(:id) + + reaction_users = + DiscourseReactions::ReactionUser + .joins(:reaction) + .where(post_id: post_ids) + .where("discourse_reactions_reactions.reaction_users_count IS NOT NULL") + + # Guarantee backwards compatibility if someone was calling this endpoint with the old param. + # TODO(roman): Remove after the 2.9 release. + before_reaction_id = params[:before_reaction_user_id] + if before_reaction_id.blank? && params[:before_post_id] + before_reaction_id = params[:before_post_id] + end + + if before_reaction_id + reaction_users = + reaction_users.where("discourse_reactions_reaction_users.id < ?", before_reaction_id.to_i) + end + + if params[:acting_username] + reaction_users = + reaction_users.joins(:user).where(users: { username: params[:acting_username] }) + end + + reaction_users = reaction_users.order(created_at: :desc).limit(20).to_a + + if params[:include_likes] + # We do not want to include likes that also count as + # a reaction, otherwise it is confusing in the UI. We + # do the same on the likes-received endpoint. + likes = + PostAction + .where( + post_id: post_ids, + deleted_at: nil, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + .joins(<<~SQL) + LEFT 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 + SQL + .where("discourse_reactions_reaction_users.id IS NULL") + .order(created_at: :desc) + .limit(20) + + if params[:before_like_id] + likes = likes.where("post_actions.id < ?", params[:before_like_id].to_i) + end + + if params[:acting_username] + likes = likes.joins(:user).where(users: { username: params[:acting_username] }) + end + + reaction_users = reaction_users.concat(translate_to_reactions(likes)) + reaction_users = reaction_users.sort { |a, b| b.created_at <=> a.created_at } + end + + render_serialized reaction_users.first(20), UserReactionSerializer + end + + def post_reactions_users + params.require(:id).to_i + reaction_value = params[:reaction_value] + post = fetch_post_from_params + + raise Discourse::InvalidParameters if !post + + reaction_users = [] + + if !reaction_value || reaction_value == DiscourseReactions::Reaction.main_reaction_id + # We only want to get likes that don't have an associated ReactionUser + # record, which count as a like or there will be double ups for main_reaction_id. + likes = + post.post_actions.where( + DiscourseReactions::PostActionExtension.filter_reaction_likes_sql, + like: PostActionType::LIKE_POST_ACTION_ID, + valid_reactions: DiscourseReactions::Reaction.valid_reactions.to_a, + ) + + # Filter out likes for reactions that are not longer enabled, + # which match up to a ReactionUser in historical data. + historical_reaction_likes = + likes + .joins( + "LEFT JOIN discourse_reactions_reaction_users ON discourse_reactions_reaction_users.user_id = post_actions.user_id AND discourse_reactions_reaction_users.post_id = post_actions.post_id", + ) + .joins( + "LEFT JOIN discourse_reactions_reactions ON discourse_reactions_reactions.id = discourse_reactions_reaction_users.reaction_id", + ) + .where( + "discourse_reactions_reactions.reaction_value NOT IN (:valid_reactions)", + valid_reactions: DiscourseReactions::Reaction.valid_reactions.to_a, + ) + + likes = likes.where.not(id: historical_reaction_likes.select(:id)) + end + + if likes.present? + main_reaction = + DiscourseReactions::Reaction.find_by( + reaction_value: DiscourseReactions::Reaction.main_reaction_id, + post_id: post.id, + ) + count = likes.length + users = format_likes_users(likes) + + # Also include ReactionUser records for main_reaction_id + # if they have been created in the past; new records created + # using main_reaction_id will only make a PostAction. + if main_reaction && main_reaction[:reaction_users_count] + (users << get_users(main_reaction)).flatten! + users.sort_by! { |user| user[:created_at] } + count += main_reaction.reaction_users_count.to_i + end + + reaction_users << { + id: DiscourseReactions::Reaction.main_reaction_id, + count: count, + users: users.reverse.slice(0, MAX_USERS_COUNT + 1), + } + end + + if !reaction_value + post + .reactions + .select do |reaction| + reaction[:reaction_users_count] && + reaction[:reaction_value] != DiscourseReactions::Reaction.main_reaction_id + end + .each { |reaction| reaction_users << format_reaction_user(reaction) } + elsif reaction_value != DiscourseReactions::Reaction.main_reaction_id + post + .reactions + .where(reaction_value: reaction_value) + .select { |reaction| reaction[:reaction_users_count] } + .each { |reaction| reaction_users << format_reaction_user(reaction) } + end + + render_json_dump(reaction_users: reaction_users) + end + + private + + def get_users(reaction) + reaction + .reaction_users + .includes(:user) + .order("discourse_reactions_reaction_users.created_at desc") + .limit(MAX_USERS_COUNT + 1) + .map do |reaction_user| + { + username: reaction_user.user.username, + name: reaction_user.user.name, + avatar_template: reaction_user.user.avatar_template, + can_undo: reaction_user.can_undo?, + created_at: reaction_user.created_at.to_s, + } + end + end + + def post_serializer(post) + PostSerializer.new(post, scope: guardian, root: false) + end + + def format_reaction_user(reaction) + { + id: reaction.reaction_value, + count: reaction.reaction_users_count.to_i, + users: get_users(reaction), + } + end + + def format_like_user(like) + { + username: like.user.username, + name: like.user.name, + avatar_template: like.user.avatar_template, + can_undo: guardian.can_delete_post_action?(like), + created_at: like.created_at.to_s, + } + end + + def format_likes_users(likes) + likes.includes([:user]).limit(MAX_USERS_COUNT + 1).map { |like| format_like_user(like) } + end + + def fetch_post_from_params + post_id = params[:post_id] || params[:id] + post = Post.find(post_id) + guardian.ensure_can_see!(post) + post + end + + def publish_change_to_clients!(post, reaction: nil, previous_reaction: nil) + message = { post_id: post.id, reactions: [reaction, previous_reaction].compact.uniq } + + opts = {} + secure_audience = post.topic.secure_audience_publish_messages + opts = secure_audience if secure_audience[:user_ids] != [] && secure_audience[:group_ids] != [] + + MessageBus.publish("/topic/#{post.topic.id}/reactions", message, opts) + end + + def secure_reaction_users!(reaction_users) + builder = DB.build("/*where*/") + UserAction.apply_common_filters(builder, current_user.id, guardian) + reaction_users.where(builder.to_sql.delete_prefix("/*where*/").delete_prefix("WHERE")) + end + + def translate_to_reactions(likes) + likes.map do |like| + DiscourseReactions::ReactionUser.new( + id: like.id, + post: like.post, + user: like.user, + created_at: like.created_at, + reaction: + DiscourseReactions::Reaction.new( + id: like.id, + reaction_type: "emoji", + post_id: like.post_id, + reaction_value: DiscourseReactions::Reaction.main_reaction_id, + created_at: like.created_at, + reaction_users_count: 1, + ), + ) + end + end +end diff --git a/plugins/discourse-reactions/app/jobs/regular/discourse_reactions/like_synchronizer.rb b/plugins/discourse-reactions/app/jobs/regular/discourse_reactions/like_synchronizer.rb new file mode 100644 index 00000000000..8c158741e32 --- /dev/null +++ b/plugins/discourse-reactions/app/jobs/regular/discourse_reactions/like_synchronizer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Jobs + module DiscourseReactions + class LikeSynchronizer < ::Jobs::Base + def execute(args = {}) + if !SiteSetting.discourse_reactions_enabled || + !SiteSetting.discourse_reactions_like_sync_enabled + return + end + + ::DiscourseReactions::ReactionLikeSynchronizer.sync! + end + end + end +end diff --git a/plugins/discourse-reactions/app/jobs/scheduled/discourse_reactions/scheduled_like_synchronizer.rb b/plugins/discourse-reactions/app/jobs/scheduled/discourse_reactions/scheduled_like_synchronizer.rb new file mode 100644 index 00000000000..e173e20a6e7 --- /dev/null +++ b/plugins/discourse-reactions/app/jobs/scheduled/discourse_reactions/scheduled_like_synchronizer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Jobs + module DiscourseReactions + class ScheduledLikeSynchronizer < ::Jobs::Scheduled + every 1.hour + + def execute(args = {}) + if !SiteSetting.discourse_reactions_enabled || + !SiteSetting.discourse_reactions_like_sync_enabled + return + end + + ::DiscourseReactions::ReactionLikeSynchronizer.sync! + end + end + end +end diff --git a/plugins/discourse-reactions/app/models/discourse_reactions/reaction.rb b/plugins/discourse-reactions/app/models/discourse_reactions/reaction.rb new file mode 100644 index 00000000000..ebe81905e60 --- /dev/null +++ b/plugins/discourse-reactions/app/models/discourse_reactions/reaction.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module DiscourseReactions + class Reaction < ActiveRecord::Base + self.table_name = "discourse_reactions_reactions" + + enum :reaction_type, { emoji: 0 } + + has_many :reaction_users, class_name: "DiscourseReactions::ReactionUser" + has_many :users, through: :reaction_users + belongs_to :post + + scope :positive, -> { where(reaction_value: self.positive_reactions) } + scope :negative_or_neutral, -> { where(reaction_value: self.negative_or_neutral_reactions) } + scope :by_user, + ->(user) do + joins(:reaction_users).where(discourse_reactions_reaction_users: { user_id: user.id }) + end + + def self.valid_reactions + Set[ + DiscourseReactions::Reaction.main_reaction_id, + *SiteSetting.discourse_reactions_enabled_reactions.to_s.split("|") + ] + end + + def self.main_reaction_id + SiteSetting.discourse_reactions_reaction_for_like.gsub("-", "") + end + + def self.reactions_excluded_from_like + SiteSetting.discourse_reactions_excluded_from_like.to_s.split("|") + end + + def self.reactions_counting_as_like + Set[ + *( + valid_reactions.to_a - reactions_excluded_from_like - + [DiscourseReactions::Reaction.main_reaction_id] + ).flatten + ] + end + end +end + +# == Schema Information +# +# Table name: discourse_reactions_reactions +# +# id :bigint not null, primary key +# post_id :integer +# reaction_type :integer +# reaction_value :string +# reaction_users_count :integer +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_discourse_reactions_reactions_on_post_id (post_id) +# reaction_type_reaction_value (post_id,reaction_type,reaction_value) UNIQUE +# diff --git a/plugins/discourse-reactions/app/models/discourse_reactions/reaction_user.rb b/plugins/discourse-reactions/app/models/discourse_reactions/reaction_user.rb new file mode 100644 index 00000000000..9bea7b091ba --- /dev/null +++ b/plugins/discourse-reactions/app/models/discourse_reactions/reaction_user.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module DiscourseReactions + # There is some slightly complex logic around reactions that + # is not immediately apparent. Some reactions also count as + # a PostAction "like" and some do not. There are three states + # that can happen when a user reacts to a post: + # + # * A PostAction record is created _without_ a ReactionUser. This + # happens when the main_reaction_id (discourse_reactions_reaction_for_like) + # is used. + # * A ReactionUser record is created _without_ a PostAction. This + # happens when a reaction that does not count as a "like" is used + # (discourse_reactions_excluded_from_like) + # * Both a PostAction and ReactionUser are created. This happens + # when a reaction that counts as a "like" is used that is _not_ + # the main_reaction_id and is _not_ in the excluded_from_like list. + # + # When the discourse_reactions_excluded_from_like setting changes, + # we sync the ReactionUser and PostAction records to delete the PostAction + # records that are no longer necessary. Changing the main_reaction_id + # does not alter history, and as such it is not recommended to do this. + class ReactionUser < ActiveRecord::Base + self.table_name = "discourse_reactions_reaction_users" + + belongs_to :reaction, class_name: "DiscourseReactions::Reaction", counter_cache: true + belongs_to :user + belongs_to :post + + delegate :username, to: :user, allow_nil: true + delegate :avatar_template, to: :user, allow_nil: true + delegate :name, to: :user, allow_nil: true + + def can_undo? + self.created_at > SiteSetting.post_undo_action_window_mins.minutes.ago + end + + def post_action_like + @post_action_like ||= + PostAction.find_by( + user_id: self.user_id, + post_id: self.post_id, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + end + + def reload + @post_action_like = nil + super + end + end +end + +# == Schema Information +# +# Table name: discourse_reactions_reaction_users +# +# id :bigint not null, primary key +# reaction_id :bigint +# user_id :integer +# created_at :datetime not null +# updated_at :datetime not null +# post_id :integer +# +# Indexes +# +# index_discourse_reactions_reaction_users_on_reaction_id (reaction_id) +# reaction_id_user_id (reaction_id,user_id) UNIQUE +# user_id_post_id (user_id,post_id) UNIQUE +# diff --git a/plugins/discourse-reactions/app/serializers/reaction_serializer.rb b/plugins/discourse-reactions/app/serializers/reaction_serializer.rb new file mode 100644 index 00000000000..b748cd223e4 --- /dev/null +++ b/plugins/discourse-reactions/app/serializers/reaction_serializer.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true +class ReactionSerializer < ApplicationSerializer + attributes :id, :post_id, :reaction_type, :reaction_value, :reaction_users_count, :created_at +end diff --git a/plugins/discourse-reactions/app/serializers/user_reaction_serializer.rb b/plugins/discourse-reactions/app/serializers/user_reaction_serializer.rb new file mode 100644 index 00000000000..fe4701e168a --- /dev/null +++ b/plugins/discourse-reactions/app/serializers/user_reaction_serializer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +class UserReactionSerializer < ApplicationSerializer + attributes :id, :user_id, :post_id, :created_at + + has_one :user, serializer: GroupPostUserSerializer, embed: :object + has_one :post, serializer: GroupPostSerializer, embed: :object + has_one :reaction, serializer: ReactionSerializer, embed: :object +end diff --git a/plugins/discourse-reactions/app/services/discourse_reactions/reaction_like_synchronizer.rb b/plugins/discourse-reactions/app/services/discourse_reactions/reaction_like_synchronizer.rb new file mode 100644 index 00000000000..4bd6ea5ee13 --- /dev/null +++ b/plugins/discourse-reactions/app/services/discourse_reactions/reaction_like_synchronizer.rb @@ -0,0 +1,416 @@ +# frozen_string_literal: true + +module DiscourseReactions + class ReactionLikeSynchronizer + def self.sync! + self.new.sync! + end + + def initialize + @excluded_from_like = DiscourseReactions::Reaction.reactions_excluded_from_like + end + + def sync! + return if !SiteSetting.discourse_reactions_like_sync_enabled + + pre_run_report = DiscourseReactions::MigrationReport.run(print_report: false) + Rails.logger.info( + "[ReactionLikeSynchronizer] Starting sync.... Pre-run report:\n\n#{DiscourseReactions::MigrationReport.humanized_report_data(pre_run_report)}", + ) + + # We want this to be all-or-nothing because the scope of each update/insert/delete + # batch is so dependent on the `all_affected_post_action_ids` or previously affected + # IDs. If we don't do this in a transaction, we could end up with a partial update + # on error and have no easy way of correcting data. + ActiveRecord::Base.transaction do + inserted_post_action_ids = create_missing_post_actions + recovered_post_action_ids = recover_trashed_post_actions + + created_or_recovered_post_action_ids = + (recovered_post_action_ids + inserted_post_action_ids).uniq + + Rails.logger.info( + "[ReactionLikeSynchronizer] Inserted #{inserted_post_action_ids.length} post action likes, recovered #{recovered_post_action_ids.length} trashed post action likes. (#{created_or_recovered_post_action_ids.length} total)", + ) + + create_missing_user_actions(created_or_recovered_post_action_ids) + + trashed_post_action_ids = trash_excluded_post_actions + Rails.logger.info( + "[ReactionLikeSynchronizer] Trashed #{trashed_post_action_ids.length} post action likes.", + ) + delete_excluded_user_actions(trashed_post_action_ids) + + all_affected_post_action_ids = + (created_or_recovered_post_action_ids + trashed_post_action_ids).uniq + + update_post_like_counts(all_affected_post_action_ids) + update_topic_like_counts(all_affected_post_action_ids) + update_user_stats(all_affected_post_action_ids) + upsert_given_daily_likes(all_affected_post_action_ids) + + TopicUser.update_post_action_cache( + post_id: + PostAction.with_deleted.where(id: all_affected_post_action_ids).pluck(:post_id).uniq, + ) + end + + post_run_report = + DiscourseReactions::MigrationReport.run( + print_report: false, + previous_report_data: pre_run_report, + ) + + Rails.logger.info( + "[ReactionLikeSynchronizer] Sync completed! Post-run report:\n\n#{DiscourseReactions::MigrationReport.humanized_report_data(post_run_report, pre_run_report)}", + ) + + { pre_run_report:, post_run_report: } + end + + # Find all ReactionUser records that do not have a corresponding + # PostAction like record, for any reactions that are not in + # excluded_from_like, and create a PostAction record for each. + def create_missing_post_actions + sql_query = <<~SQL + INSERT INTO post_actions( + post_id, user_id, post_action_type_id, created_at, updated_at + ) + SELECT ru.post_id, ru.user_id, :pa_like, ru.created_at, ru.updated_at + FROM discourse_reactions_reaction_users ru + INNER JOIN discourse_reactions_reactions + ON discourse_reactions_reactions.id = ru.reaction_id + LEFT JOIN post_actions + ON post_actions.user_id = ru.user_id + AND post_actions.post_id = ru.post_id + WHERE post_actions.id IS NULL + #{@excluded_from_like.any? ? " AND discourse_reactions_reactions.reaction_value NOT IN (:excluded_from_like)" : ""} + RETURNING post_actions.id + SQL + + DB.query_single( + sql_query, + pa_like: PostActionType::LIKE_POST_ACTION_ID, + excluded_from_like: @excluded_from_like, + ) + end + + # Find all trashed PostAction records matching ReactionUser records, + # which are not in excluded_from_like, and untrash them. + def recover_trashed_post_actions + sql_query = <<~SQL + UPDATE post_actions + SET deleted_at = NULL, deleted_by_id = NULL, updated_at = NOW() + FROM discourse_reactions_reaction_users ru + INNER JOIN discourse_reactions_reactions + ON discourse_reactions_reactions.id = ru.reaction_id + WHERE post_actions.deleted_at IS NOT NULL AND post_actions.user_id = ru.user_id + AND post_actions.post_id = ru.post_id AND post_actions.post_action_type_id = :pa_like + #{@excluded_from_like.any? ? " AND discourse_reactions_reactions.reaction_value NOT IN (:excluded_from_like)" : ""} + SQL + + DB.query_single( + sql_query, + pa_like: PostActionType::LIKE_POST_ACTION_ID, + excluded_from_like: @excluded_from_like, + ) + end + + # Create the corresponding UserAction records for the PostAction records. In + # the ReactionManager, this is done via PostActionCreator. + # + # The only difference between LIKE and WAS LIKED is the user; + # * LIKE is the post action user because they are the one who liked the post + # * WAS LIKED is done by the post user, because they are the like-ee + # + # No need to do any UserAction inserts if there wasn't any PostAction changes. + def create_missing_user_actions(post_action_ids) + return if post_action_ids.none? + + sql_query = <<~SQL + INSERT INTO user_actions ( + action_type, user_id, acting_user_id, target_post_id, target_topic_id, created_at, updated_at + ) + SELECT :ua_like, + post_actions.user_id, + post_actions.user_id, + post_actions.post_id, + posts.topic_id, + post_actions.created_at, + post_actions.created_at + FROM post_actions + INNER JOIN posts ON posts.id = post_actions.post_id + WHERE post_actions.id IN (:post_action_ids) AND posts.user_id IS NOT NULL + ON CONFLICT DO NOTHING + SQL + inserted_user_action_count = + DB.exec(sql_query, ua_like: UserAction::LIKE, post_action_ids: post_action_ids) + Rails.logger.info( + "[ReactionsLikeSynchronizer] Inserted #{inserted_user_action_count} like UserActions", + ) + + sql_query = <<~SQL + INSERT INTO user_actions ( + action_type, user_id, acting_user_id, target_post_id, target_topic_id, created_at, updated_at + ) + SELECT :ua_was_liked, + posts.user_id, + post_actions.user_id, + post_actions.post_id, + posts.topic_id, + post_actions.created_at, + post_actions.created_at + FROM post_actions + INNER JOIN posts ON posts.id = post_actions.post_id + WHERE post_actions.id IN (:post_action_ids) AND posts.user_id IS NOT NULL + ON CONFLICT DO NOTHING + SQL + inserted_user_action_count = + DB.exec(sql_query, ua_was_liked: UserAction::WAS_LIKED, post_action_ids: post_action_ids) + Rails.logger.info( + "[ReactionsLikeSynchronizer] Inserted #{inserted_user_action_count} was_liked UserActions", + ) + end + + # Delete any UserAction records for LIKE or WAS_LIKED that match up with + # PostAction records that got trashed. + def delete_excluded_user_actions(trashed_post_action_ids) + return if trashed_post_action_ids.empty? + + sql_query = <<~SQL + DELETE FROM user_actions + WHERE id IN ( + -- Select IDs for LIKED actions + SELECT user_actions.id + FROM user_actions + INNER JOIN post_actions ON user_actions.target_post_id = post_actions.post_id + AND user_actions.acting_user_id = post_actions.user_id + WHERE post_actions.id IN (:trashed_post_action_ids) + AND user_actions.action_type = :ua_like + + UNION + + -- Select IDs for WAS_LIKED actions + SELECT user_actions.id + FROM user_actions user_actions + INNER JOIN post_actions ON user_actions.target_post_id = post_actions.post_id + INNER JOIN posts ON posts.id = post_actions.post_id + WHERE post_actions.id IN (:trashed_post_action_ids) + AND user_actions.action_type = :ua_was_liked + AND user_actions.user_id = posts.user_id + AND user_actions.acting_user_id = post_actions.user_id + ) + SQL + DB.exec( + sql_query, + ua_like: UserAction::LIKE, + ua_was_liked: UserAction::WAS_LIKED, + trashed_post_action_ids: trashed_post_action_ids, + ) + end + + # Find all PostAction records that have a ReactionUser record that + # uses a reaction in the excluded_from_like list and trash them. + def trash_excluded_post_actions + return [] if @excluded_from_like.none? + + sql_query = <<~SQL + UPDATE post_actions + SET deleted_at = NOW() + FROM discourse_reactions_reaction_users ru + INNER JOIN discourse_reactions_reactions ON discourse_reactions_reactions.id = ru.reaction_id + WHERE post_actions.user_id = ru.user_id + AND post_actions.post_id = ru.post_id + AND post_actions.post_action_type_id = :like + AND discourse_reactions_reactions.reaction_value IN (:excluded_from_like) + RETURNING post_actions.id + SQL + + DB.query_single( + sql_query, + like: PostActionType::LIKE_POST_ACTION_ID, + excluded_from_like: @excluded_from_like, + ua_like: UserAction::LIKE, + ua_was_liked: UserAction::WAS_LIKED, + ) + end + + # Update the like_count counter cache on all Post records + # affected by created/recovered/trashed post actions. + def update_post_like_counts(all_affected_post_action_ids) + post_ids = DB.query_single(<<~SQL, post_action_ids: all_affected_post_action_ids) + SELECT DISTINCT post_id + FROM post_actions + WHERE ID IN (:post_action_ids) + SQL + + sql_query = <<~SQL + UPDATE posts + SET like_count = ( + SELECT COUNT(*) + FROM post_actions + WHERE post_actions.post_id = posts.id + AND post_action_type_id = 2 + AND post_actions.deleted_at IS NULL + ) + WHERE posts.id IN (:post_ids) + SQL + DB.exec(sql_query, post_ids: post_ids) + end + + # Update the like_count counter cache on all Topic records + # affected by created/recovered/trashed post actions. + def update_topic_like_counts(all_affected_post_action_ids) + topic_ids = DB.query_single(<<~SQL, post_action_ids: all_affected_post_action_ids) + SELECT DISTINCT topic_id + FROM posts + INNER JOIN post_actions ON post_actions.post_id = posts.id + WHERE post_actions.id IN (:post_action_ids) + SQL + + sql_query = <<~SQL + UPDATE topics + SET like_count = ( + SELECT SUM(like_count) + FROM posts + WHERE posts.topic_id = topics.id + ) + WHERE topics.id IN (:topic_ids) + SQL + + DB.exec(sql_query, topic_ids: topic_ids) + end + + # Update the likes_given and likes_received counter caches on the + # UserStat table based on posts matching up with created/restored/trashed + # post actions. + # + # The UserAction records (which are created/deleted before this) are an + # easier way to calculate this rather than going via PostAction again. + def update_user_stats(all_affected_post_action_ids) + return if all_affected_post_action_ids.empty? + + users_needing_likes_received_recalc = DB.query_single(<<~SQL, all_affected_post_action_ids) + SELECT DISTINCT posts.user_id + FROM posts + INNER JOIN post_actions ON post_actions.post_id = posts.id + WHERE post_actions.id IN (?) + SQL + + if users_needing_likes_received_recalc.any? + # NOTE: UserAction created as a result of a PostAction like + # will have acting_user_id, target_post_id, and target_topic_id + # filled but NOT target_user_id, see UserActionManager.post_action_rows + sql_query = <<~SQL + WITH likes_received_cte AS ( + SELECT posts.user_id AS user_id, COUNT(user_actions.id) AS new_likes_received + FROM user_actions + INNER JOIN posts ON user_actions.target_post_id = posts.id + WHERE user_actions.action_type = :ua_was_liked + AND posts.user_id IN (:affected_user_ids) + GROUP BY posts.user_id + ) + UPDATE user_stats + SET likes_received = lrc.new_likes_received + FROM likes_received_cte lrc + WHERE user_stats.user_id = lrc.user_id + RETURNING user_stats.user_id + SQL + + changed_user_ids = + DB.query_single( + sql_query, + affected_user_ids: users_needing_likes_received_recalc, + ua_was_liked: UserAction::WAS_LIKED, + ) + UserStat.where(user_id: users_needing_likes_received_recalc - changed_user_ids).update_all( + likes_received: 0, + ) + end + + users_needing_likes_given_recalc = DB.query_single(<<~SQL, all_affected_post_action_ids) + SELECT DISTINCT user_id + FROM post_actions + WHERE post_actions.id IN (?) + SQL + + if users_needing_likes_given_recalc.any? + sql_query = <<~SQL + WITH likes_given_cte AS ( + SELECT user_actions.acting_user_id AS user_id, COUNT(user_actions.id) AS new_likes_given + FROM user_actions + WHERE user_actions.action_type = :ua_like + AND user_actions.acting_user_id IN (:affected_user_ids) + GROUP BY user_actions.acting_user_id + ) + UPDATE user_stats + SET likes_given = lgc.new_likes_given + FROM likes_given_cte lgc + WHERE user_stats.user_id = lgc.user_id + RETURNING user_stats.user_id + SQL + + changed_user_ids = + DB.query_single( + sql_query, + affected_user_ids: users_needing_likes_given_recalc, + ua_like: UserAction::LIKE, + ) + UserStat.where(user_id: users_needing_likes_given_recalc - changed_user_ids).update_all( + likes_given: 0, + ) + end + end + + # Upsert any existing GivenDailyLike records for the users who created + # or were affected by the created/restored/trashed post actions. There + # is a count per day per user that needs to be recalculated. + # + # We delete any GivenDailyLike records that would equate to a count of 0 + # for that day and that user, which is based on UserAction records (which + # are created or destroyed before this). + def upsert_given_daily_likes(all_affected_post_action_ids) + return if all_affected_post_action_ids.blank? + + sql_query = <<~SQL + SELECT user_id + FROM post_actions + WHERE post_actions.id = ANY(ARRAY[:all_affected_post_action_ids]) + UNION + SELECT posts.user_id + FROM posts + INNER JOIN post_actions ON post_actions.post_id = posts.id + WHERE post_actions.id = ANY(ARRAY[:all_affected_post_action_ids]) + SQL + user_ids = + DB.query_single(sql_query, all_affected_post_action_ids: all_affected_post_action_ids) + + return if user_ids.blank? + + sql_query = <<~SQL + INSERT INTO given_daily_likes (user_id, given_date, likes_given) + SELECT user_actions.acting_user_id, DATE(user_actions.created_at) AS given_date, COUNT(*) AS likes_given + FROM user_actions + WHERE user_actions.action_type = :ua_like + AND user_actions.acting_user_id IN (:user_ids) + GROUP BY user_actions.acting_user_id, DATE(user_actions.created_at) + ON CONFLICT (user_id, given_date) + DO UPDATE SET likes_given = EXCLUDED.likes_given + SQL + DB.exec(sql_query, ua_like: UserAction::LIKE, user_ids: user_ids) + + sql_query = <<~SQL + DELETE FROM given_daily_likes gdl + WHERE NOT EXISTS ( + SELECT 1 + FROM user_actions + WHERE + user_actions.acting_user_id = gdl.user_id + AND user_actions.action_type = :ua_like + AND DATE(user_actions.created_at) = gdl.given_date + ) AND gdl.user_id IN (:user_ids) + SQL + DB.exec(sql_query, ua_like: UserAction::LIKE, user_ids: user_ids) + end + end +end diff --git a/plugins/discourse-reactions/app/services/discourse_reactions/reaction_manager.rb b/plugins/discourse-reactions/app/services/discourse_reactions/reaction_manager.rb new file mode 100644 index 00000000000..b3471ec894e --- /dev/null +++ b/plugins/discourse-reactions/app/services/discourse_reactions/reaction_manager.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +module DiscourseReactions + class ReactionManager + attr_reader :reaction_value, :previous_reaction_value + + def initialize(reaction_value:, user:, post:) + @reaction_value = reaction_value + @user = user + @post = post + @like = + @post.post_actions.find_by( + user: @user, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + @previous_reaction_value = + if @like && !reaction_user + DiscourseReactions::Reaction.main_reaction_id + elsif reaction_user + old_reaction_value(reaction_user) + end + end + + def toggle! + if (@like && !@user.guardian.can_delete_post_action?(@like)) || + (reaction_user && !@user.guardian.can_delete_reaction_user?(reaction_user)) + raise Discourse::InvalidAccess + end + + ActiveRecord::Base.transaction do + @reaction = reaction_scope&.first_or_create + @reaction_user = reaction_user_scope + if @reaction_value == DiscourseReactions::Reaction.main_reaction_id + toggle_like + else + toggle_reaction + end + end + end + + private + + def toggle_like + remove_reaction if reaction_user.present? + @like ? remove_shadow_like : add_shadow_like + end + + def toggle_reaction + if reaction_user.present? + remove_reaction + return if previous_reaction_value && previous_reaction_value == @reaction_value + end + + remove_shadow_like if @like + add_reaction if reaction_user.blank? + end + + def add_reaction_notification + DiscourseReactions::ReactionNotification.new(@reaction, @user).create + end + + def remove_reaction_notification + DiscourseReactions::ReactionNotification.new(@reaction, @user).delete + end + + def reaction_scope + DiscourseReactions::Reaction.where( + post_id: @post.id, + reaction_value: @reaction_value, + reaction_type: DiscourseReactions::Reaction.reaction_types["emoji"], + ) + end + + def reaction_user_scope + return nil unless @reaction + search_reaction_user = + DiscourseReactions::ReactionUser.where(user_id: @user.id, post_id: @post.id) + create_reaction_user = + DiscourseReactions::ReactionUser.new( + reaction_id: @reaction.id, + user_id: @user.id, + post_id: @post.id, + ) + search_reaction_user.length > 0 ? search_reaction_user.first : create_reaction_user + end + + def reaction_user + DiscourseReactions::ReactionUser.find_by(user_id: @user.id, post_id: @post.id) + end + + def old_reaction_value(reaction_user) + return unless reaction_user + DiscourseReactions::Reaction.where(id: reaction_user.reaction_id).first&.reaction_value + end + + def add_shadow_like(notify: true) + silent = true + PostActionCreator.like(@user, @post, silent) + add_reaction_notification if notify + end + + def remove_shadow_like + PostActionDestroyer.new(@user, @post, PostActionType::LIKE_POST_ACTION_ID).perform + delete_like_reaction + remove_reaction_notification + end + + def delete_like_reaction + DiscourseReactions::Reaction.where( + reaction_value: DiscourseReactions::Reaction.main_reaction_id, + post_id: @post.id, + ).destroy_all + end + + def add_reaction + @reaction_user = reaction_user_scope if reaction_user.blank? + @reaction_user.save! + add_shadow_like(notify: false) if !reaction_excluded_from_like? + add_reaction_notification + end + + def remove_reaction + @reaction_user.destroy + remove_shadow_like + delete_reaction_with_no_users + end + + def delete_reaction_with_no_users + DiscourseReactions::Reaction.where(reaction_users_count: 0, post_id: @post.id).destroy_all + end + + def reaction_excluded_from_like? + DiscourseReactions::Reaction.reactions_excluded_from_like.include?(@reaction_value) + end + end +end diff --git a/plugins/discourse-reactions/app/services/discourse_reactions/reaction_notification.rb b/plugins/discourse-reactions/app/services/discourse_reactions/reaction_notification.rb new file mode 100644 index 00000000000..6a9732df79b --- /dev/null +++ b/plugins/discourse-reactions/app/services/discourse_reactions/reaction_notification.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module DiscourseReactions + class ReactionNotification + HEART_ICON_NAME = "heart" + + def initialize(reaction, user) + @reaction = reaction + @post = reaction.post + @user = user + end + + def create + post_user = @post.user + + if post_user.user_option&.like_notification_frequency == + UserOption.like_notification_frequency_type[:never] + return + end + + opts = { user_id: @user.id, display_username: @user.username, display_name: @user.name } + + if @reaction.reaction_value == HEART_ICON_NAME + opts[:custom_data] = { reaction_icon: @reaction.reaction_value } + end + + PostAlerter.new.create_notification(post_user, Notification.types[:reaction], @post, opts) + end + + def delete + return if DiscourseReactions::Reaction.where(post_id: @post.id).by_user(@user).count != 0 + read = true + Notification + .where( + topic_id: @post.topic_id, + user_id: @post.user_id, + post_number: @post.post_number, + notification_type: Notification.types[:reaction], + ) + .each do |notification| + read = false unless notification.read + notification.destroy + end + refresh_notification(read) + end + + private + + def remaining_reaction_data + @post + .reactions + .joins(:users) + .order("discourse_reactions_reactions.created_at DESC") + .where("discourse_reactions_reactions.created_at > ?", 1.day.ago) + .pluck(:username, :name, :reaction_value) + end + + def refresh_notification(read) + return unless @post && @post.user_id && @post.topic + + remaining_data = remaining_reaction_data + return if remaining_data.blank? + + data = { + topic_title: @post.topic.title, + count: remaining_data.length, + username: remaining_data[0][0], + display_username: remaining_data[0][0], + display_name: remaining_data[0][1], + } + + data[:username2] = remaining_data[1][0] if remaining_data[1] + data[:name2] = remaining_data[1][1] if remaining_data[1] + + if remaining_data.all? { |element| element[2] == HEART_ICON_NAME } + data[:reaction_icon] = HEART_ICON_NAME + end + + Notification.create( + notification_type: Notification.types[:reaction], + topic_id: @post.topic_id, + post_number: @post.post_number, + user_id: @post.user_id, + read: read, + data: data.to_json, + ) + end + end +end diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/adapters/discourse-reactions-adapter.js b/plugins/discourse-reactions/assets/javascripts/discourse/adapters/discourse-reactions-adapter.js new file mode 100644 index 00000000000..00ee6f4e09a --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/adapters/discourse-reactions-adapter.js @@ -0,0 +1,7 @@ +import RestAdapter from "discourse/adapters/rest"; + +export default class DiscourseReactionsAdapter extends RestAdapter { + basePath() { + return "/discourse-reactions/"; + } +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/adapters/discourse-reactions-custom-reaction.js b/plugins/discourse-reactions/assets/javascripts/discourse/adapters/discourse-reactions-custom-reaction.js new file mode 100644 index 00000000000..6fdfd9d8873 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/adapters/discourse-reactions-custom-reaction.js @@ -0,0 +1,14 @@ +import DiscourseReactionsAdapter from "./discourse-reactions-adapter"; + +export default class DiscourseReactionsCustomReaction extends DiscourseReactionsAdapter { + pathFor(store, type, findArgs) { + const path = + this.basePath(store, type, findArgs) + + store.pluralize(this.apiNameFor(type)); + return this.appendQueryParams(path, findArgs); + } + + apiNameFor() { + return "custom-reaction"; + } +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-actions-button.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-actions-button.gjs new file mode 100644 index 00000000000..78e5a331fbc --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-actions-button.gjs @@ -0,0 +1,12 @@ +import DiscourseReactionsActions from "./discourse-reactions-actions"; + +const ReactionsActionButton = ; + +export default ReactionsActionButton; diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-actions-summary.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-actions-summary.gjs new file mode 100644 index 00000000000..bd13f69e908 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-actions-summary.gjs @@ -0,0 +1,31 @@ +import Component from "@glimmer/component"; +import DiscourseReactionsActions from "./discourse-reactions-actions"; + +export default class ReactionsActionSummary extends Component { + static extraControls = true; + + static shouldRender(args, context, owner) { + const site = owner.lookup("service:site"); + + if (site.mobileView || args.post.deleted) { + return false; + } + + const siteSettings = owner.lookup("service:site-settings"); + const mainReaction = siteSettings.discourse_reactions_reaction_for_like; + + return !( + args.post.reactions && + args.post.reactions.length === 1 && + args.post.reactions[0].id === mainReaction + ); + } + + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-actions.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-actions.gjs new file mode 100644 index 00000000000..72229faec43 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-actions.gjs @@ -0,0 +1,787 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { cancel, later, run, schedule } from "@ember/runloop"; +import { service } from "@ember/service"; +import { createPopper } from "@popperjs/core"; +import curryComponent from "ember-curry-component"; +import $ from "jquery"; +import { Promise } from "rsvp"; +import { and, eq, not } from "truth-helpers"; +import lazyHash from "discourse/helpers/lazy-hash"; +import { isTesting } from "discourse/lib/environment"; +import { emojiUrlFor } from "discourse/lib/text"; +import closeOnClickOutside from "discourse/modifiers/close-on-click-outside"; +import { i18n } from "discourse-i18n"; +import CustomReaction from "../models/discourse-reactions-custom-reaction"; +import DiscourseReactionsCounter from "./discourse-reactions-counter"; +import DiscourseReactionsDoubleButton from "./discourse-reactions-double-button"; +import DiscourseReactionsPicker from "./discourse-reactions-picker"; +import DiscourseReactionsReactionButton from "./discourse-reactions-reaction-button"; + +const VIBRATE_DURATION = 5; + +let _popperPicker; +let _currentReactionWidget; + +export function resetCurrentReaction() { + _currentReactionWidget = null; +} + +function buildFakeReaction(reactionId) { + const img = document.createElement("img"); + img.src = emojiUrlFor(reactionId); + img.classList.add( + "btn-toggle-reaction-emoji", + "reaction-button", + "fake-reaction" + ); + + return img; +} + +function moveReactionAnimation( + postContainer, + reactionId, + startPosition, + endPosition, + complete +) { + if (isTesting()) { + return; + } + + const fakeReaction = buildFakeReaction(reactionId); + const reactionButton = postContainer.querySelector(".reaction-button"); + + reactionButton.appendChild(fakeReaction); + + let done = () => { + fakeReaction.remove(); + complete(); + }; + + fakeReaction.style.top = startPosition; + fakeReaction.style.opacity = 0; + + $(fakeReaction).animate( + { + top: endPosition, + opacity: 1, + }, + { + duration: 350, + complete: done, + }, + "swing" + ); +} + +function addReaction(list, reactionId, complete) { + moveReactionAnimation(list, reactionId, "-50px", "8px", complete); +} + +function dropReaction(list, reactionId, complete) { + moveReactionAnimation(list, reactionId, "8px", "42px", complete); +} + +function scaleReactionAnimation(mainReaction, start, end, complete) { + if (isTesting()) { + return run(this, complete); + } + + return $(mainReaction) + .stop() + .css("textIndent", start) + .animate( + { textIndent: end }, + { + complete, + step(now) { + $(this) + .css("transform", `scale(${now})`) + .addClass("far-heart") + .removeClass("heart"); + }, + duration: 150, + }, + "linear" + ); +} + +export default class DiscourseReactionsActions extends Component { + @service dialog; + @service capabilities; + @service siteSettings; + @service site; + @service currentUser; + + @tracked reactionsPickerExpanded = false; + @tracked statePanelExpanded = false; + + get classes() { + const { post } = this.args; + if (!post.reactions) { + return; + } + + const hasReactions = post.reactions.length; + const hasReacted = post.current_user_reaction; + const customReactionUsed = + post.reactions.length && + post.reactions.filter( + (reaction) => + reaction.id !== + this.siteSettings.discourse_reactions_reaction_for_like + ).length; + const classes = []; + + if (customReactionUsed) { + classes.push("custom-reaction-used"); + } + + if (post.yours) { + classes.push("my-post"); + } + + if (hasReactions) { + classes.push("has-reactions"); + } + + if (hasReacted) { + classes.push("has-reacted"); + } + + if (post.current_user_used_main_reaction) { + classes.push("has-used-main-reaction"); + } + + if ( + (!post.current_user_reaction || post.current_user_reaction.can_undo) && + post.likeAction?.canToggle + ) { + classes.push("can-toggle-reaction"); + } + + return classes.join(" "); + } + + @action + toggleReactions(event) { + if (!this.reactionsPickerExpanded) { + if (this.statePanelExpanded) { + this.scheduleExpand("expandReactionsPicker"); + } else { + this.expandReactionsPicker(event); + } + } + } + + @action + touchStart() { + this._validTouch = true; + + cancel(this._touchTimeout); + + if (this.capabilities.touch) { + document.documentElement?.classList?.toggle( + "discourse-reactions-no-select", + true + ); + + this._touchStartAt = Date.now(); + this._touchTimeout = later(() => { + this._touchStartAt = null; + this.toggleReactions(); + }, 400); + return false; + } + } + + @action + touchMove() { + // if users move while touching we consider it as a scroll and don't want to + // trigger the reaction or the picker + this._validTouch = false; + cancel(this._touchTimeout); + } + + @action + touchEnd(event) { + cancel(this._touchTimeout); + + if (!this._validTouch) { + return; + } + + if (this.capabilities.touch) { + if (event.changedTouches.length) { + const endTarget = document.elementFromPoint( + event.changedTouches[0].clientX, + event.changedTouches[0].clientY + ); + + if (endTarget) { + const parentNode = endTarget.parentNode; + + if (endTarget.classList.contains("pickable-reaction")) { + endTarget.click(); + return; + } else if ( + parentNode && + parentNode.classList.contains("pickable-reaction") + ) { + parentNode.click(); + return; + } + } + } + + const duration = Date.now() - (this._touchStartAt || 0); + this._touchStartAt = null; + if (duration > 400) { + if ( + event && + event.target && + event.target.classList.contains("discourse-reactions-reaction-button") + ) { + this.toggleReactions(event); + } + } else { + if ( + event.target && + (event.target.classList.contains( + "discourse-reactions-reaction-button" + ) || + event.target.classList.contains("reaction-button")) + ) { + this.toggleFromButton({ + reaction: this.args.post.current_user_reaction + ? this.args.post.current_user_reaction.id + : this.siteSettings.discourse_reactions_reaction_for_like, + }); + } + } + } + } + + @action + toggle(params) { + if (!this.currentUser) { + if (this.args.showLogin) { + this.args.showLogin(); + return; + } + } + + if ( + !this.args.post.current_user_reaction || + (this.args.post.current_user_reaction.can_undo && + this.args.post.likeAction.canToggle) + ) { + if (this.capabilities.userHasBeenActive && this.capabilities.canVibrate) { + navigator.vibrate(VIBRATE_DURATION); + } + + const pickedReaction = document.querySelector( + `[data-post-id="${ + params.postId + }"] .discourse-reactions-picker .pickable-reaction.${CSS.escape( + params.reaction + )} .emoji` + ); + + const scales = [1.0, 1.75]; + return new Promise((resolve) => { + scaleReactionAnimation(pickedReaction, scales[0], scales[1], () => { + scaleReactionAnimation(pickedReaction, scales[1], scales[0], () => { + const post = this.args.post; + const postContainer = document.querySelector( + `[data-post-id="${params.postId}"]` + ); + + if ( + post.current_user_reaction && + post.current_user_reaction.id === params.reaction + ) { + this.toggleReaction(params); + + later(() => { + dropReaction(postContainer, params.reaction, () => { + return CustomReaction.toggle(this.args.post, params.reaction) + .then(resolve) + .catch((e) => { + this.dialog.alert(this._extractErrors(e)); + this._rollbackState(post); + }); + }); + }, 100); + } else { + addReaction(postContainer, params.reaction, () => { + this.toggleReaction(params); + + CustomReaction.toggle(this.args.post, params.reaction) + .then(resolve) + .catch((e) => { + this.dialog.alert(this._extractErrors(e)); + this._rollbackState(post); + }); + }); + } + }); + }); + }).finally(() => { + this.collapseAllPanels(); + }); + } + } + + toggleReaction(attrs) { + this.collapseAllPanels(); + + if ( + this.args.post.current_user_reaction && + !this.args.post.current_user_reaction.can_undo && + !this.args.post.likeAction.canToggle + ) { + return; + } + + const post = this.args.post; + + if (post.current_user_reaction) { + post.reactions.every((reaction, index) => { + if ( + reaction.count <= 1 && + reaction.id === post.current_user_reaction.id + ) { + post.reactions.splice(index, 1); + return false; + } else if (reaction.id === post.current_user_reaction.id) { + post.reactions[index].count -= 1; + + return false; + } + + return true; + }); + } + + if ( + attrs.reaction && + (!post.current_user_reaction || + attrs.reaction !== post.current_user_reaction.id) + ) { + let isAvailable = false; + + post.reactions.every((reaction, index) => { + if (reaction.id === attrs.reaction) { + post.reactions[index].count += 1; + isAvailable = true; + return false; + } + return true; + }); + + if (!isAvailable) { + const newReaction = { + id: attrs.reaction, + type: "emoji", + count: 1, + }; + + const tempReactions = Object.assign([], post.reactions); + + tempReactions.push(newReaction); + + //sorts reactions and get index of new reaction + const newReactionIndex = tempReactions + .sort((reaction1, reaction2) => { + if (reaction1.count > reaction2.count) { + return -1; + } + if (reaction1.count < reaction2.count) { + return 1; + } + + //if count is same, sort it by id + if (reaction1.id > reaction2.id) { + return 1; + } + if (reaction1.id < reaction2.id) { + return -1; + } + }) + .indexOf(newReaction); + + post.reactions.splice(newReactionIndex, 0, newReaction); + } + + if (!post.current_user_reaction) { + post.reaction_users_count += 1; + } + + post.current_user_reaction = { + id: attrs.reaction, + type: "emoji", + can_undo: true, + }; + } else { + post.reaction_users_count -= 1; + post.current_user_reaction = null; + } + + if ( + post.current_user_reaction && + post.current_user_reaction.id === + this.siteSettings.discourse_reactions_reaction_for_like + ) { + post.current_user_used_main_reaction = true; + } else { + post.current_user_used_main_reaction = false; + } + + // Trigger re-render for anything autotracking reactions. + // In future, we should make reactions a deeply-trackable structure. + // eslint-disable-next-line no-self-assign + post.reactions = post.reactions; + } + + @action + toggleFromButton(attrs) { + if (!this.currentUser) { + if (this.args.showLogin) { + this.args.showLogin(); + return; + } + } + + this.collapseAllPanels(); + + const mainReactionName = + this.siteSettings.discourse_reactions_reaction_for_like; + const post = this.args.post; + const current_user_reaction = post.current_user_reaction; + + if ( + post.likeAction && + !(post.likeAction.canToggle || post.likeAction.can_undo) + ) { + return; + } + + if ( + this.args.post.current_user_reaction && + !this.args.post.current_user_reaction.can_undo + ) { + return; + } + + if (!this.currentUser || post.user_id === this.currentUser.id) { + return; + } + + if (this.capabilities.userHasBeenActive && this.capabilities.canVibrate) { + navigator.vibrate(VIBRATE_DURATION); + } + + if (current_user_reaction && current_user_reaction.id === attrs.reaction) { + this.toggleReaction(attrs); + return CustomReaction.toggle(this.args.post, attrs.reaction).catch( + (e) => { + this.dialog.alert(this._extractErrors(e)); + this._rollbackState(post); + } + ); + } + + let selector; + if ( + post.reactions && + post.reactions.length === 1 && + post.reactions[0].id === mainReactionName + ) { + selector = `[data-post-id="${this.args.post.id}"] .discourse-reactions-double-button .discourse-reactions-reaction-button .d-icon`; + } else { + if (!attrs.reaction || attrs.reaction === mainReactionName) { + selector = `[data-post-id="${this.args.post.id}"] .discourse-reactions-reaction-button .d-icon`; + } else { + selector = `[data-post-id="${this.args.post.id}"] .discourse-reactions-reaction-button .reaction-button .btn-toggle-reaction-emoji`; + } + } + + const mainReaction = document.querySelector(selector); + + const scales = [1.0, 1.5]; + return new Promise((resolve) => { + scaleReactionAnimation(mainReaction, scales[0], scales[1], () => { + scaleReactionAnimation(mainReaction, scales[1], scales[0], () => { + this.toggleReaction(attrs); + + let toggleReaction = + attrs.reaction && attrs.reaction !== mainReactionName + ? attrs.reaction + : this.siteSettings.discourse_reactions_reaction_for_like; + + CustomReaction.toggle(this.args.post, toggleReaction) + .then(resolve) + .catch((e) => { + this.dialog.alert(this._extractErrors(e)); + this._rollbackState(post); + }); + }); + }); + }); + } + + @action + cancelCollapse() { + cancel(this._collapseHandler); + } + + @action + cancelExpand() { + cancel(this._expandHandler); + } + + scheduleExpand(handler) { + this.cancelExpand(); + + this._expandHandler = later(this, this[handler], 250); + } + + @action + scheduleCollapse(handler) { + this.cancelCollapse(); + + this._collapseHandler = later(this, this[handler], 500); + } + + get elementId() { + return `discourse-reactions-actions-${this.args.post.id}-${ + this.args.position || "right" + }`; + } + + @action + clickOutside() { + if (this.reactionsPickerExpanded || this.statePanelExpanded) { + this.collapseAllPanels(); + } + } + + expandReactionsPicker() { + cancel(this._collapseHandler); + _currentReactionWidget?.collapseAllPanels(); + this.statePanelExpanded = false; + this.reactionsPickerExpanded = true; + this._setupPopper([ + ".discourse-reactions-reaction-button", + ".discourse-reactions-picker", + ]); + } + + @action + expandStatePanel() { + cancel(this._collapseHandler); + _currentReactionWidget?.collapseAllPanels(); + this.statePanelExpanded = true; + this.reactionsPickerExpanded = false; + this._setupPopper([ + ".discourse-reactions-counter", + ".discourse-reactions-state-panel", + ]); + } + + @action + collapseStatePanel() { + cancel(this._collapseHandler); + this._collapseHandler = null; + this.statePanelExpanded = false; + } + + collapseReactionsPicker() { + cancel(this._collapseHandler); + this._collapseHandler = null; + this.reactionsPickerExpanded = false; + } + + @action + collapseAllPanels() { + cancel(this._collapseHandler); + document.documentElement?.classList?.toggle( + "discourse-reactions-no-select", + false + ); + this._collapseHandler = null; + this.statePanelExpanded = false; + this.reactionsPickerExpanded = false; + } + + @action + updatePopperPosition() { + _popperPicker?.update(); + } + + _setupPopper(selectors) { + schedule("afterRender", () => { + const position = this.args.position || "right"; + const id = this.args.post.id; + const trigger = document.querySelector( + `#discourse-reactions-actions-${id}-${position} ${selectors[0]}` + ); + const popper = document.querySelector( + `#discourse-reactions-actions-${id}-${position} ${selectors[1]}` + ); + + _popperPicker?.destroy(); + _popperPicker = this._applyPopper(trigger, popper); + _currentReactionWidget = this; + }); + } + + _applyPopper(button, picker) { + return createPopper(button, picker, { + placement: "top", + modifiers: [ + { + name: "offset", + options: { + offset: [0, -5], + }, + }, + { + name: "preventOverflow", + options: { + padding: 5, + }, + }, + ], + }); + } + + _rollbackState(post) { + const current_user_reaction = post.current_user_reaction; + const current_user_used_main_reaction = + post.current_user_used_main_reaction; + const reactions = Object.assign([], post.reactions); + const reaction_users_count = post.reaction_users_count; + + post.current_user_reaction = current_user_reaction; + post.current_user_used_main_reaction = current_user_used_main_reaction; + post.reactions = reactions; + post.reaction_users_count = reaction_users_count; + } + + _extractErrors(e) { + const xhr = e.xhr || e.jqXHR; + + if (!xhr || !xhr.status) { + return i18n("errors.desc.network"); + } + + if ( + xhr.status === 429 && + xhr.responseJSON && + xhr.responseJSON.errors && + xhr.responseJSON.errors[0] + ) { + return xhr.responseJSON.errors[0]; + } else if (xhr.status === 403) { + return i18n("discourse_reactions.reaction.forbidden"); + } else { + return i18n("errors.desc.unknown"); + } + } + + get onlyOneMainReaction() { + return ( + this.args.post.reactions?.length === 1 && + this.args.post.reactions[0].id === + this.siteSettings.discourse_reactions_reaction_for_like + ); + } + + get showReactionsPicker() { + return ( + this.currentUser && + this.args.post.user_id !== this.currentUser.id && + this.reactionsPickerExpanded + ); + } + + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-counter.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-counter.gjs new file mode 100644 index 00000000000..188b15697ef --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-counter.gjs @@ -0,0 +1,217 @@ +import Component from "@glimmer/component"; +import { hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { TrackedObject } from "@ember-compat/tracked-built-ins"; +import { and } from "truth-helpers"; +import icon from "discourse/helpers/d-icon"; +import { bind } from "discourse/lib/decorators"; +import closeOnClickOutside from "discourse/modifiers/close-on-click-outside"; +import CustomReaction from "../models/discourse-reactions-custom-reaction"; +import DiscourseReactionsList from "./discourse-reactions-list"; +import DiscourseReactionsStatePanel from "./discourse-reactions-state-panel"; + +export default class DiscourseReactionsCounter extends Component { + @service capabilities; + @service site; + @service siteSettings; + + reactionsUsers = new TrackedObject(); + + get elementId() { + return `discourse-reactions-counter-${this.args.post.id}-${ + this.args.position || "right" + }`; + } + + reactionsChanged(data) { + data.reactions.uniq().forEach((reaction) => { + this.getUsers(reaction); + }); + } + + @bind + getUsers(reactionValue) { + return CustomReaction.findReactionUsers(this.args.post.id, { + reactionValue, + }).then((response) => { + response.reaction_users.forEach((reactionUser) => { + this.reactionsUsers[reactionUser.id] = reactionUser.users; + }); + + this.args.updatePopperPosition(); + }); + } + + @action + mouseDown(event) { + event.stopImmediatePropagation(); + return false; + } + + @action + mouseUp(event) { + event.stopImmediatePropagation(); + return false; + } + + @action + click(event) { + if (event.target.closest("[data-user-card]")) { + return; + } + + this.args.cancelCollapse(); + + if (!this.capabilities.touch || !this.site.mobileView) { + event.stopPropagation(); + event.preventDefault(); + + if (!this.args.statePanelExpanded) { + this.getUsers(); + } + + this.toggleStatePanel(event); + } + } + + @action + clickOutside() { + if (this.args.statePanelExpanded) { + this.args.collapseAllPanels(); + } + } + + @action + touchStart(event) { + this.args.cancelCollapse(); + + if ( + event.target.classList.contains("show-users") || + event.target.classList.contains("avatar") + ) { + return true; + } + + if (this.args.statePanelExpanded) { + event.stopPropagation(); + event.preventDefault(); + return; + } + + if (this.capabilities.touch) { + event.stopPropagation(); + event.preventDefault(); + this.getUsers(); + this.toggleStatePanel(event); + } + } + + get classes() { + const classes = []; + const mainReaction = + this.siteSettings.discourse_reactions_reaction_for_like; + + const { post } = this.args; + + if ( + post.reactions && + post.reactions.length === 1 && + post.reactions[0].id === mainReaction + ) { + classes.push("only-like"); + } + + if (post.reaction_users_count > 0) { + classes.push("discourse-reactions-counter"); + } + + return classes.join(" "); + } + + toggleStatePanel() { + if (!this.args.statePanelExpanded) { + this.args.expandStatePanel(); + } else { + this.args.collapseStatePanel(); + } + } + + @action + pointerOver(event) { + if (event.pointerType !== "mouse") { + return; + } + + this.args.cancelCollapse(); + } + + @action + pointerOut(event) { + if (event.pointerType !== "mouse") { + return; + } + + if (!event.relatedTarget?.closest(`#${this.elementId}`)) { + this.args.scheduleCollapse("collapseStatePanel"); + } + } + + get onlyOneMainReaction() { + return ( + this.args.post.reactions?.length === 1 && + this.args.post.reactions[0].id === + this.siteSettings.discourse_reactions_reaction_for_like + ); + } + + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-double-button.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-double-button.gjs new file mode 100644 index 00000000000..0800a6c41a9 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-double-button.gjs @@ -0,0 +1,13 @@ +const DiscourseReactionsDoubleButton = ; + +export default DiscourseReactionsDoubleButton; diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-list-emoji.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-list-emoji.gjs new file mode 100644 index 00000000000..a8ea2bd356f --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-list-emoji.gjs @@ -0,0 +1,135 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { debounce, schedule } from "@ember/runloop"; +import { service } from "@ember/service"; +import { createPopper } from "@popperjs/core"; +import emoji from "discourse/helpers/emoji"; +import { bind } from "discourse/lib/decorators"; +import { i18n } from "discourse-i18n"; + +const DISPLAY_MAX_USERS = 19; +let _popperReactionUserPanel; + +export default class DiscourseReactionsListEmoji extends Component { + @service siteSettings; + + @tracked loadingReactions = false; + + get elementId() { + return `discourse-reactions-list-emoji-${this.args.post.id}-${this.args.reaction.id}`; + } + + @action + pointerOver(event) { + if (event.pointerType !== "mouse") { + return; + } + + this._setupPopper(".user-list"); + + if (!this.args.users?.length && !this.loadingReactions) { + debounce(this, this._loadReactionUsers, 3000, true); + } + } + + _setupPopper(selector) { + schedule("afterRender", () => { + const elementId = CSS.escape(this.elementId); + const trigger = document.querySelector(`#${elementId}`); + const popperElement = document.querySelector(`#${elementId} ${selector}`); + + if (popperElement) { + _popperReactionUserPanel && _popperReactionUserPanel.destroy(); + _popperReactionUserPanel = createPopper(trigger, popperElement, { + placement: "bottom", + modifiers: [ + { + name: "offset", + options: { + offset: [0, -5], + }, + }, + { + name: "preventOverflow", + options: { + padding: 5, + }, + }, + ], + }); + } + }); + } + + _loadReactionUsers() { + this.loadingReactions = true; + this.args.getUsers(this.args.reaction.id).finally(() => { + this.loadingReactions = false; + }); + } + + get truncatedUsers() { + return this.args.users?.slice(0, DISPLAY_MAX_USERS); + } + + @bind + displayNameForUser(user) { + if ( + !this.siteSettings.prioritize_username_in_ux && + this.siteSettings.prioritize_full_name_in_ux + ) { + return user.name || user.username; + } else if (this.siteSettings.prioritize_username_in_ux) { + return user.username; + } else if (!user.name) { + return user.username; + } else { + return user.name; + } + } + + get hiddenUserCount() { + return this.args.users?.length - this.truncatedUsers?.length; + } + + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-list.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-list.gjs new file mode 100644 index 00000000000..c86a55b71a6 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-list.gjs @@ -0,0 +1,21 @@ +import { get } from "@ember/helper"; +import DiscourseReactionsListEmoji from "./discourse-reactions-list-emoji"; + +const DiscourseReactionsList = ; + +export default DiscourseReactionsList; diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-picker.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-picker.gjs new file mode 100644 index 00000000000..15b02154c1e --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-picker.gjs @@ -0,0 +1,158 @@ +import Component from "@glimmer/component"; +import { fn, hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import concatClass from "discourse/helpers/concat-class"; +import emoji from "discourse/helpers/emoji"; +import { i18n } from "discourse-i18n"; + +export default class DiscourseReactionsPicker extends Component { + @service siteSettings; + + @action + pointerOut(event) { + if (event.pointerType !== "mouse") { + return; + } + + this.args.scheduleCollapse("collapseReactionsPicker"); + } + + @action + pointerOver() { + if (event.pointerType !== "mouse") { + return; + } + + this.args.cancelCollapse(); + } + + get reactionInfo() { + const reactions = this.siteSettings.discourse_reactions_enabled_reactions + .split("|") + .filter(Boolean); + + if ( + !reactions.includes( + this.siteSettings.discourse_reactions_reaction_for_like + ) + ) { + reactions.unshift( + this.siteSettings.discourse_reactions_reaction_for_like + ); + } + + const { post } = this.args; + const currentUserReaction = post.current_user_reaction; + + return reactions.map((reaction) => { + let isUsed; + let canUndo; + + if ( + reaction === this.siteSettings.discourse_reactions_reaction_for_like + ) { + isUsed = post.current_user_used_main_reaction; + } else { + isUsed = currentUserReaction && currentUserReaction.id === reaction; + } + + if (currentUserReaction) { + canUndo = currentUserReaction.can_undo && post.likeAction.canToggle; + } else { + canUndo = post.likeAction.canToggle; + } + + let title; + let titleOptions; + if (canUndo) { + title = "discourse_reactions.picker.react_with"; + titleOptions = { reaction }; + } else { + title = "discourse_reactions.picker.cant_remove_reaction"; + } + + return { + id: reaction, + title: i18n(title, titleOptions), + canUndo, + isUsed, + }; + }); + } + + _getOptimalColsCount(count) { + let x; + const colsByRow = [5, 6, 7, 8]; + + // if small count, just use it + if (count < colsByRow[0]) { + return count; + } + + for (let index = 0; index < colsByRow.length; ++index) { + const i = colsByRow[index]; + + // if same as one of the max cols number, just use it + let rest = count % i; + if (rest === 0) { + x = i; + break; + } + + // loop until we find a number limiting to the minimum the number + // of empty cells + if (index === 0) { + x = i; + } else { + if (rest > count % (i - 1)) { + x = i; + } + } + } + + return x; + } + + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-reaction-button.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-reaction-button.gjs new file mode 100644 index 00000000000..84844708a3d --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-reaction-button.gjs @@ -0,0 +1,150 @@ +import Component from "@glimmer/component"; +import { concat } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { isBlank } from "@ember/utils"; +import icon from "discourse/helpers/d-icon"; +import { emojiUrlFor } from "discourse/lib/text"; +import { i18n } from "discourse-i18n"; + +export default class ReactionsReactionButton extends Component { + @service capabilities; + @service siteSettings; + @service site; + @service currentUser; + + @action + click() { + this.args.cancelCollapse(); + + const currentUserReaction = this.args.post.current_user_reaction; + if (!this.capabilities.touch || !this.site.mobileView) { + this.args.toggleFromButton({ + reaction: currentUserReaction + ? currentUserReaction.id + : this.siteSettings.discourse_reactions_reaction_for_like, + }); + } + } + + @action + pointerOver(event) { + if (event.pointerType !== "mouse") { + return; + } + + this.args.cancelCollapse(); + + const likeAction = this.args.post.likeAction; + const currentUserReaction = this.args.post.current_user_reaction; + if ( + currentUserReaction && + !currentUserReaction.can_undo && + (!likeAction || isBlank(likeAction.can_undo)) + ) { + return; + } + + this.args.toggleReactions(event); + } + + @action + pointerOut(event) { + if (event.pointerType !== "mouse") { + return; + } + + this.args.cancelExpand(); + this.args.scheduleCollapse("collapseReactionsPicker"); + } + + get title() { + if (!this.currentUser) { + return i18n("discourse_reactions.main_reaction.unauthenticated"); + } + + const likeAction = this.args.post.likeAction; + if (!likeAction) { + return null; + } + + let title; + let options; + const currentUserReaction = this.args.post.current_user_reaction; + + if (likeAction.canToggle && isBlank(likeAction.can_undo)) { + title = "discourse_reactions.main_reaction.add"; + } + + if (likeAction.canToggle && likeAction.can_undo) { + title = "discourse_reactions.main_reaction.remove"; + } + + if (!likeAction.canToggle) { + title = "discourse_reactions.main_reaction.cant_remove"; + } + + if ( + currentUserReaction && + currentUserReaction.can_undo && + isBlank(likeAction.can_undo) + ) { + title = "discourse_reactions.picker.remove_reaction"; + options = { reaction: currentUserReaction.id }; + } + + if ( + currentUserReaction && + !currentUserReaction.can_undo && + isBlank(likeAction.can_undo) + ) { + title = "discourse_reactions.picker.cant_remove_reaction"; + } + + return options ? i18n(title, options) : i18n(title); + } + + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-reaction-post.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-reaction-post.gjs new file mode 100644 index 00000000000..a3320ec69eb --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-reaction-post.gjs @@ -0,0 +1,161 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import { action } from "@ember/object"; +import { equal } from "@ember/object/computed"; +import { service } from "@ember/service"; +import UserStreamItem from "discourse/components/user-stream-item"; +import avatar from "discourse/helpers/avatar"; +import { ajax } from "discourse/lib/ajax"; +import getURL from "discourse/lib/get-url"; +import { emojiUrlFor } from "discourse/lib/text"; + +let cachedNames = new Map(); +let pendingSearch = null; + +export default class DiscourseReactionsReactionPost extends Component { + @service site; + @service siteSettings; + + @tracked updatedExcerpt = this.args.reaction.post.excerpt; + @tracked updatedExpandedExcerpt = this.args.reaction.post.expandedExcerpt; + + @equal("args.reaction.post.post_type", "site.post_types.moderator_action") + moderatorAction; + + constructor() { + super(...arguments); + this.updateMentionedUsernames(); + } + + @action + async updateMentionedUsernames() { + this.updatedExcerpt = await this.replaceMentionsWithFullNames( + this.args.reaction.post.excerpt + ); + this.updatedExpandedExcerpt = await this.replaceMentionsWithFullNames( + this.args.reaction.post.expandedExcerpt + ); + } + + async replaceMentionsWithFullNames(text) { + if (!text) { + return text; + } + + const mentionRegex = /@([\p{L}\d._]+)/gu; + + const replacedText = await Promise.all( + text.split(mentionRegex).map(async (part, index) => { + if (index % 2 === 1) { + const fullName = await this.searchUsername(part); + return `@${fullName || part}`; + } + return part; + }) + ); + + return replacedText.join(""); + } + + async searchUsername(username) { + username = username.toLowerCase(); + + if (cachedNames.has(username)) { + return cachedNames.get(username); + } + + if (pendingSearch?.usernames.size <= 50) { + pendingSearch.usernames.add(username); + const results = await pendingSearch.search; + + if (results.searchedUsernames.includes(username)) { + const fullName = + results.data.users?.find( + (user) => user.username.toLowerCase() === username + )?.name || + results.data.groups?.find( + (group) => group.name.toLowerCase() === username + )?.full_name; + + cachedNames.set(username, fullName); + return fullName; + } + } + + return this.deferSearch(username); + } + + async deferSearch(username) { + const usernamesList = new Set([username]); + + pendingSearch = { + search: new Promise((resolve) => { + setTimeout(async () => { + const searchedUsernames = Array.from(usernamesList); + pendingSearch = null; + + const data = await ajax("/u/search/users.json", { + data: { + usernames: searchedUsernames.join(","), + include_groups: this.siteSettings.show_fullname_for_groups, + }, + }); + + resolve({ searchedUsernames, data }); + }, 20); + }), + usernames: usernamesList, + }; + + return this.searchUsername(username); + } + + get postUrl() { + return getURL(this.args.reaction.post.url); + } + + get emojiUrl() { + const reactionValue = this.args.reaction.reaction.reaction_value; + return reactionValue ? emojiUrlFor(reactionValue) : null; + } + + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-state-panel-reaction.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-state-panel-reaction.gjs new file mode 100644 index 00000000000..43e6a571e42 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-state-panel-reaction.gjs @@ -0,0 +1,103 @@ +import Component from "@glimmer/component"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { gt } from "truth-helpers"; +import UserAvatar from "discourse/components/user-avatar"; +import concatClass from "discourse/helpers/concat-class"; +import icon from "discourse/helpers/d-icon"; +import emoji from "discourse/helpers/emoji"; +import { i18n } from "discourse-i18n"; + +const MAX_USERS_COUNT = 26; +const MIN_USERS_COUNT = 8; + +export default class DiscourseReactionsStatePanelReaction extends Component { + @action + click(event) { + if (event?.target?.classList?.contains("show-users")) { + event.preventDefault(); + event.stopPropagation(); + + this.args.showUsers(this.args.reaction.id); + } + } + + get firstLineUsers() { + return this.args.users.slice(0, MIN_USERS_COUNT); + } + + get otherUsers() { + return this.args.users.slice(MIN_USERS_COUNT, MAX_USERS_COUNT); + } + + get columnsCount() { + return this.args.users.length > MIN_USERS_COUNT + ? this.firstLineUsers.length + 1 + : this.firstLineUsers.length; + } + + get moreLabel() { + if (this.args.isDisplayed && this.args.reaction.count > MAX_USERS_COUNT) { + return i18n("discourse_reactions.state_panel.more_users", { + count: this.args.reaction.count - MAX_USERS_COUNT, + }); + } + } + + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-state-panel.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-state-panel.gjs new file mode 100644 index 00000000000..90ac33bfe1c --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/components/discourse-reactions-state-panel.gjs @@ -0,0 +1,96 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { on } from "@ember/modifier"; +import { action, get } from "@ember/object"; +import { and, eq } from "truth-helpers"; +import concatClass from "discourse/helpers/concat-class"; +import DiscourseReactionsStatePanelReaction from "./discourse-reactions-state-panel-reaction"; + +export default class DiscourseReactionsStatePanel extends Component { + @tracked displayedReactionId; + + get classes() { + const classes = []; + + const { post } = this.args; + if (post?.reactions) { + const maxCount = Math.max(...post.reactions.mapBy("count")); + const charsCount = maxCount.toString().length; + classes.push(`max-length-${charsCount}`); + } + + if (this.args.statePanelExpanded) { + classes.push("is-expanded"); + } + + return classes; + } + + @action + pointerOut(event) { + if (event.pointerType !== "mouse") { + return; + } + + this.args.scheduleCollapse("collapseStatePanel"); + } + + @action + pointerOver(event) { + if (event.pointerType !== "mouse") { + return; + } + + this.args.cancelCollapse(); + } + + @action + showUsers(reactionId) { + if (!this.displayedReactionId) { + this.displayedReactionId = reactionId; + } else if (this.displayedReactionId === reactionId) { + this.hideUsers(); + } else if (this.displayedReactionId !== reactionId) { + this.displayedReactionId = reactionId; + } + } + + @action + hideUsers() { + this.displayedReactionId = null; + } + + get hasReactionData() { + return !!Object.keys(this.args.reactionsUsers).length; + } + + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/connectors/user-activity-bottom/discourse-reactions-user-activity-reactions.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/connectors/user-activity-bottom/discourse-reactions-user-activity-reactions.gjs new file mode 100644 index 00000000000..0b3ac4b969a --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/connectors/user-activity-bottom/discourse-reactions-user-activity-reactions.gjs @@ -0,0 +1,21 @@ +import Component from "@ember/component"; +import { LinkTo } from "@ember/routing"; +import { classNames, tagName } from "@ember-decorators/component"; +import icon from "discourse/helpers/d-icon"; +import { i18n } from "discourse-i18n"; + +@tagName("li") +@classNames( + "user-activity-bottom-outlet", + "discourse-reactions-user-activity-reactions" +) +export default class DiscourseReactionsUserActivityReactions extends Component { + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/connectors/user-notifications-bottom/discourse-reactions-user-notification-reactions.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/connectors/user-notifications-bottom/discourse-reactions-user-notification-reactions.gjs new file mode 100644 index 00000000000..2d890bda917 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/connectors/user-notifications-bottom/discourse-reactions-user-notification-reactions.gjs @@ -0,0 +1,21 @@ +import Component from "@ember/component"; +import { LinkTo } from "@ember/routing"; +import { classNames, tagName } from "@ember-decorators/component"; +import icon from "discourse/helpers/d-icon"; +import { i18n } from "discourse-i18n"; + +@tagName("li") +@classNames( + "user-notifications-bottom-outlet", + "discourse-reactions-user-notification-reactions" +) +export default class DiscourseReactionsUserNotificationReactions extends Component { + +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/controllers/user-activity-reactions.js b/plugins/discourse-reactions/assets/javascripts/discourse/controllers/user-activity-reactions.js new file mode 100644 index 00000000000..fe59110c7d2 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/controllers/user-activity-reactions.js @@ -0,0 +1,76 @@ +import { tracked } from "@glimmer/tracking"; +import Controller, { inject as controller } from "@ember/controller"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import CustomReaction from "../models/discourse-reactions-custom-reaction"; + +export default class UserActivityReactions extends Controller { + @service siteSettings; + @controller application; + + @tracked canLoadMore = true; + @tracked loading = false; + @tracked beforeLikeId = null; + @tracked beforeReactionUserId = null; + + #getLastIdFrom(array) { + return array.length ? array[array.length - 1].get("id") : null; + } + + #updateBeforeIds(reactionUsers) { + if (this.includeLikes) { + const mainReaction = + this.siteSettings.discourse_reactions_reaction_for_like; + const [likes, reactions] = reactionUsers.reduce( + (memo, elem) => { + if (elem.reaction.reaction_value === mainReaction) { + memo[0].push(elem); + } else { + memo[1].push(elem); + } + + return memo; + }, + [[], []] + ); + + this.beforeLikeId = this.#getLastIdFrom(likes); + this.beforeReactionUserId = this.#getLastIdFrom(reactions); + } else { + this.beforeReactionUserId = this.#getLastIdFrom(reactionUsers); + } + } + + @action + loadMore() { + if (!this.canLoadMore || this.loading) { + return; + } + + this.loading = true; + const reactionUsers = this.model; + + if (!this.beforeReactionUserId) { + this.#updateBeforeIds(reactionUsers); + } + + const opts = { + actingUsername: this.actingUsername, + includeLikes: this.includeLikes, + beforeLikeId: this.beforeLikeId, + beforeReactionUserId: this.beforeReactionUserId, + }; + + CustomReaction.findReactions(this.reactionsUrl, this.username, opts) + .then((newReactionUsers) => { + reactionUsers.addObjects(newReactionUsers); + this.#updateBeforeIds(newReactionUsers); + if (newReactionUsers.length === 0) { + this.canLoadMore = false; + } + }) + .finally(() => { + this.loading = false; + }); + } +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/discourse-reactions-user-activity-route-map.js b/plugins/discourse-reactions/assets/javascripts/discourse/discourse-reactions-user-activity-route-map.js new file mode 100644 index 00000000000..0ea21bb0fb7 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/discourse-reactions-user-activity-route-map.js @@ -0,0 +1,6 @@ +export default { + resource: "user.userActivity", + map() { + this.route("reactions"); + }, +}; diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/discourse-reactions-user-notifications-route-map.js b/plugins/discourse-reactions/assets/javascripts/discourse/discourse-reactions-user-notifications-route-map.js new file mode 100644 index 00000000000..16476e4c560 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/discourse-reactions-user-notifications-route-map.js @@ -0,0 +1,6 @@ +export default { + resource: "user.userNotifications", + map() { + this.route("reactionsReceived", { path: "reactions-received" }); + }, +}; diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/initializers/discourse-reactions.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/initializers/discourse-reactions.gjs new file mode 100644 index 00000000000..9ddc84271f1 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/initializers/discourse-reactions.gjs @@ -0,0 +1,219 @@ +import { withSilencedDeprecations } from "discourse/lib/deprecated"; +import { replaceIcon } from "discourse/lib/icon-library"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { emojiUrlFor } from "discourse/lib/text"; +import { userPath } from "discourse/lib/url"; +import { formatUsername } from "discourse/lib/utilities"; +import { i18n } from "discourse-i18n"; +import { resetCurrentReaction } from "../components/discourse-reactions-actions"; +import ReactionsActionButton from "../components/discourse-reactions-actions-button"; +import ReactionsActionSummary from "../components/discourse-reactions-actions-summary"; + +const PLUGIN_ID = "discourse-reactions"; + +replaceIcon("notification.reaction", "bell"); + +function initializeDiscourseReactions(api) { + customizePostMenu(api); + customizeWidgetPost(api); + + api.addKeyboardShortcut("l", null, { + click: ".topic-post.selected .discourse-reactions-reaction-button", + }); + + api.addTrackedPostProperties( + "current_user_used_main_reaction", + "reaction_users_count", + "current_user_reaction", + "reactions" + ); + + api.modifyClass( + "component:emoji-value-list", + { + pluginId: PLUGIN_ID, + + didReceiveAttrs() { + this._super(...arguments); + + if (this.setting.setting !== "discourse_reactions_enabled_reactions") { + return; + } + + let defaultValue = this.values.includes( + this.siteSettings.discourse_reactions_reaction_for_like + ); + + if (!defaultValue) { + this.collection.unshiftObject({ + emojiUrl: emojiUrlFor( + this.siteSettings.discourse_reactions_reaction_for_like + ), + isEditable: false, + isEditing: false, + value: this.siteSettings.discourse_reactions_reaction_for_like, + }); + } else { + const mainEmoji = this.collection.findBy( + "value", + this.siteSettings.discourse_reactions_reaction_for_like + ); + + if (mainEmoji) { + mainEmoji.isEditable = false; + } + } + }, + }, + // It's an admin component so it's not always present + { ignoreMissing: true } + ); + + api.replaceIcon("notification.reaction", "discourse-emojis"); + + if (api.registerNotificationTypeRenderer) { + api.registerNotificationTypeRenderer("reaction", (NotificationTypeBase) => { + return class extends NotificationTypeBase { + get linkTitle() { + return i18n("notifications.titles.reaction"); + } + + get linkHref() { + const superHref = super.linkHref; + if (superHref) { + return superHref; + } + let activityName = "reactions-received"; + + // All collapsed notifications were "likes" + if (this.notification.data.reaction_icon) { + activityName = "likes-received"; + } + return userPath( + `${this.currentUser.username}/notifications/${activityName}?acting_username=${this.notification.data.display_username}&include_likes=true` + ); + } + + get icon() { + return ( + this.notification.data.reaction_icon || + `notification.${this.notificationName}` + ); + } + + get label() { + const count = this.notification.data.count; + const nameOrUsername = this.siteSettings.prioritize_full_name_in_ux + ? this.notification.data.display_name || this.username + : this.username; + + if (!count || count === 1 || !this.notification.data.username2) { + return nameOrUsername; + } + if (count > 2) { + return i18n("notifications.reaction_multiple_users", { + username: nameOrUsername, + count: count - 1, + }); + } else { + const nameOrUsername2 = this.siteSettings.prioritize_full_name_in_ux + ? this.notification.data.name2 || + formatUsername(this.notification.data.username2) + : formatUsername(this.notification.data.username2); + return i18n("notifications.reaction_2_users", { + username: nameOrUsername, + username2: nameOrUsername2, + }); + } + } + + get labelClasses() { + if (this.notification.data.username2) { + if (this.notification.data.count > 2) { + return ["multi-user"]; + } else { + return ["double-user"]; + } + } + } + + get description() { + if ( + this.notification.data.count > 1 && + !this.notification.data.username2 + ) { + return i18n("notifications.reaction_1_user_multiple_posts", { + count: this.notification.data.count, + }); + } + return super.description; + } + }; + }); + } +} + +function customizeWidgetPost(api) { + withSilencedDeprecations("discourse.post-stream-widget-overrides", () => { + api.modifyClass("component:scrolling-post-stream", { + pluginId: PLUGIN_ID, + + didInsertElement() { + this._super(...arguments); + + const topicId = this?.posts?.firstObject?.topic_id; + if (topicId) { + this.messageBus.subscribe(`/topic/${topicId}/reactions`, (data) => { + this.dirtyKeys.keyDirty( + `discourse-reactions-counter-${data.post_id}`, + { + onRefresh: "reactionsChanged", + refreshArg: data, + } + ); + this._refresh({ id: data.post_id }); + }); + } + }, + }); + + api.modifyClass("controller:topic", { + pluginId: PLUGIN_ID, + + unsubscribe() { + this._super(...arguments); + + const topicId = this.model.id; + topicId && this.messageBus.unsubscribe(`/topic/${topicId}/reactions`); + }, + }); + }); +} + +function customizePostMenu(api) { + api.registerValueTransformer( + "post-menu-buttons", + ({ value: dag, context: { buttonKeys } }) => { + dag.replace(buttonKeys.LIKE, ReactionsActionButton); + dag.add("discourse-reactions-actions", ReactionsActionSummary, { + after: buttonKeys.REPLIES, + }); + } + ); +} + +export default { + name: "discourse-reactions", + + initialize(container) { + const siteSettings = container.lookup("service:site-settings"); + + if (siteSettings.discourse_reactions_enabled) { + withPluginApi("1.34.0", initializeDiscourseReactions); + } + }, + + teardown() { + resetCurrentReaction(); + }, +}; diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/models/discourse-reactions-custom-reaction.js b/plugins/discourse-reactions/assets/javascripts/discourse/models/discourse-reactions-custom-reaction.js new file mode 100644 index 00000000000..a808a77c68a --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/models/discourse-reactions-custom-reaction.js @@ -0,0 +1,77 @@ +import EmberObject from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import Category from "discourse/models/category"; +import Post from "discourse/models/post"; +import RestModel from "discourse/models/rest"; +import Topic from "discourse/models/topic"; +import User from "discourse/models/user"; + +export default class CustomReaction extends RestModel { + static toggle(post, reactionId) { + return ajax( + `/discourse-reactions/posts/${post.id}/custom-reactions/${reactionId}/toggle.json`, + { type: "PUT" } + ).then((result) => { + post.appEvents.trigger("discourse-reactions:reaction-toggled", { + post: result, + reaction: result.current_user_reaction, + }); + }); + } + + static findReactions(url, username, opts) { + opts = opts || {}; + const data = { username }; + + if (opts.beforeReactionUserId) { + data.before_reaction_user_id = opts.beforeReactionUserId; + } + + if (opts.beforeLikeId) { + data.before_like_id = opts.beforeLikeId; + } + + if (opts.includeLikes) { + data.include_likes = opts.includeLikes; + } + + if (opts.actingUsername) { + data.acting_username = opts.actingUsername; + } + + return ajax(`/discourse-reactions/posts/${url}.json`, { + data, + }).then((reactions) => { + return reactions.map((reaction) => { + reaction.user = User.create(reaction.user); + reaction.topic = Topic.create(reaction.post.topic); + reaction.post_user = User.create(reaction.post.user); + reaction.category = Category.findById(reaction.post.category_id); + + const postAttrs = { ...reaction.post }; + delete postAttrs.url; // Auto-calculated by Model implementation + + reaction.post = Post.create(postAttrs); + return EmberObject.create(reaction); + }); + }); + } + + static findReactionUsers(postId, opts) { + opts = opts || {}; + const data = {}; + + if (opts.reactionValue) { + data.reaction_value = opts.reactionValue; + } + + return ajax(`/discourse-reactions/posts/${postId}/reactions-users.json`, { + data, + }); + } + + init() { + super.init(...arguments); + this.__type = "discourse-reactions-custom-reaction"; + } +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/routes/user-activity-reactions.js b/plugins/discourse-reactions/assets/javascripts/discourse/routes/user-activity-reactions.js new file mode 100644 index 00000000000..445e102d081 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/routes/user-activity-reactions.js @@ -0,0 +1,21 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import CustomReaction from "../models/discourse-reactions-custom-reaction"; + +export default class UserActivityReactions extends DiscourseRoute { + model() { + return CustomReaction.findReactions( + "reactions", + this.modelFor("user").get("username") + ); + } + + setupController(controller, model) { + let loadedAll = model.length < 20; + this.controllerFor("user-activity-reactions").setProperties({ + model, + canLoadMore: !loadedAll, + reactionsUrl: "reactions", + username: this.modelFor("user").get("username"), + }); + } +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/routes/user-notifications-reactions-received.js b/plugins/discourse-reactions/assets/javascripts/discourse/routes/user-notifications-reactions-received.js new file mode 100644 index 00000000000..3a555f79a56 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/routes/user-notifications-reactions-received.js @@ -0,0 +1,36 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import CustomReaction from "../models/discourse-reactions-custom-reaction"; + +export default class UserNotificationsReactionsReceived extends DiscourseRoute { + templateName = "user-activity-reactions"; + controllerName = "user-activity-reactions"; + + queryParams = { + acting_username: { refreshModel: true }, + include_likes: { refreshModel: true }, + }; + + model(params) { + return CustomReaction.findReactions( + "reactions-received", + this.modelFor("user").get("username"), + { + actingUsername: params.acting_username, + includeLikes: params.include_likes, + } + ); + } + + setupController(controller, model) { + let loadedAll = model.length < 20; + this.controllerFor("user-activity-reactions").setProperties({ + model, + canLoadMore: !loadedAll, + reactionsUrl: "reactions-received", + username: this.modelFor("user").get("username"), + actingUsername: controller.acting_username, + includeLikes: controller.include_likes, + }); + this.controllerFor("application").set("showFooter", loadedAll); + } +} diff --git a/plugins/discourse-reactions/assets/javascripts/discourse/templates/user-activity-reactions.gjs b/plugins/discourse-reactions/assets/javascripts/discourse/templates/user-activity-reactions.gjs new file mode 100644 index 00000000000..3ca7f220698 --- /dev/null +++ b/plugins/discourse-reactions/assets/javascripts/discourse/templates/user-activity-reactions.gjs @@ -0,0 +1,21 @@ +import RouteTemplate from "ember-route-template"; +import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; +import LoadMore from "discourse/components/load-more"; +import { i18n } from "discourse-i18n"; +import DiscourseReactionsReactionPost from "../components/discourse-reactions-reaction-post"; + +export default RouteTemplate( + +); diff --git a/plugins/discourse-reactions/assets/stylesheets/common/discourse-reactions.scss b/plugins/discourse-reactions/assets/stylesheets/common/discourse-reactions.scss new file mode 100644 index 00000000000..ae6ada97cd1 --- /dev/null +++ b/plugins/discourse-reactions/assets/stylesheets/common/discourse-reactions.scss @@ -0,0 +1,508 @@ +html.discourse-reactions-no-select { + -webkit-touch-callout: none; /* Disables long-touch menu */ + + @include user-select(none); /* Disable text selection */ +} + +.discourse-reactions-list { + display: flex; + align-items: center; + justify-content: flex-end; + + .reactions { + display: flex; + align-items: center; + + .discourse-reactions-list-emoji { + border-radius: 100px; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + + &:nth-of-type(1n + 4) { + display: none; + } + + .emoji { + width: 1.1em; + height: 1.1em; + + &.desaturated { + filter: grayscale(100%); + + &:hover { + filter: grayscale(0%); + } + } + } + + .discourse-no-touch & { + &:hover { + .user-list { + visibility: visible; + opacity: 0.9; + + .spinner { + animation-play-state: running; + } + } + } + } + } + + .user-list { + position: absolute; + visibility: hidden; + z-index: z("usercard") - 2; + transition: opacity 0.33s; + opacity: 0; + padding: 0.5em 0; + min-width: 120px; + min-height: 80px; + + .spinner { + animation-play-state: paused; + } + + .container { + margin-top: 0.5em; + font-size: $font-down-2; + background-color: var(--primary); + color: var(--secondary-very-high); + border-radius: 3px; + padding: 1em; + display: flex; + flex-direction: column; + + .heading { + font-weight: 700; + font-size: $font-up-1; + padding-bottom: 0.5em; + text-align: left; + } + + .username { + @include ellipsis; + } + + .username, + .other-users { + white-space: nowrap; + text-align: left; + } + + .other-users { + padding-top: 0.25em; + color: var(--secondary-high); + } + } + } + } + + .users { + font-size: $font-down-1; + color: var(--primary-medium); + margin-left: 0.5em; + } +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + visibility: visible; + opacity: 1; + } +} + +.discourse-reactions-picker { + z-index: z("usercard") - 2; + position: absolute; + visibility: hidden; + padding: 15px 0; + + &.is-expanded { + visibility: visible; + } + + .discourse-reactions-picker-container { + margin: 0.25em; + box-shadow: var(--shadow-card); + background-color: var(--secondary); + z-index: 1; + border-radius: 8px; + display: grid; + grid-gap: 0.25em; + + @for $i from 1 through 8 { + &.col-#{$i} { + grid-template-columns: repeat($i, 1fr); + } + } + } + + .pickable-reaction { + // TODO fix this in core + padding: 0.25em !important; + margin: 0.25em !important; + border: 1px solid transparent !important; + cursor: not-allowed; + border-radius: 8px; + + .emoji { + pointer-events: none; + height: 1.2em; + width: 1.2em; + } + + &:not(.can-undo) { + background: var(--primary-very-low); + } + + &.can-undo { + cursor: pointer; + + .discourse-no-touch & { + &:hover { + background: none !important; + border-color: transparent !important; + transform: scale(1.5); + } + } + } + + &.is-used { + border-color: var(--tertiary-low) !important; + } + } +} + +.discourse-reactions-counter .discourse-reactions-state-panel { + cursor: default; + z-index: z("usercard") - 2; + position: absolute; + visibility: hidden; + padding: 15px 0; + + &.is-expanded { + min-width: 80px; + visibility: visible; + } + + .discourse-reactions-state-panel-reaction .count { + padding-right: 0.25em; + } + + @for $i from 1 through 5 { + &.max-length-#{$i} { + .discourse-reactions-state-panel-reaction .count { + width: #{$i * 10}px; + } + } + } + + .container { + box-shadow: var(--shadow-card); + background-color: var(--secondary); + display: flex; + flex-direction: column; + z-index: 1; + padding: 0.25em; + border-radius: 8px; + } + + .counters { + display: flex; + flex-direction: column; + max-height: 200px; + overflow-y: auto; + } +} + +.discourse-reactions-state-panel-reaction { + border-bottom: 1px solid var(--primary-low); + display: flex; + justify-content: flex-start; + align-items: flex-start; + + &:first-child { + padding-top: 0; + } + + &:last-child { + border: none; + padding-bottom: 0; + } + + .list { + display: grid; + grid-column-gap: 0.25em; + grid-row-gap: 0.25em; + padding-top: 0.25em; + grid-template-rows: 22.5px; + } + + @for $i from 1 through 9 { + .list-columns-#{$i} { + grid-template-columns: repeat($i, 22.5px); + } + } + + .users { + display: flex; + flex-direction: column; + + .trigger-user-card { + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + + .avatar { + // constrain to container + width: 100%; + height: 100%; + } + } + } + + .more { + color: var(--primary-medium); + font-size: $font-down-1; + padding-top: 0.5em; + text-align: left; + } + + .reaction-wrapper { + display: flex; + align-items: center; + justify-content: center; + padding-top: 0.25em; + margin-left: 0 !important; // core rule has too much specificity + + .count { + font-size: $font-down-1; + color: var(--primary-medium); + text-align: left; + + @include ellipsis; + } + + .emoji-wrapper { + width: 25px; + height: 25px; + display: flex; + align-items: center; + justify-content: center; + } + } + + .show-users { + padding: 0 !important; + margin: 0 !important; + background: none !important; + border-color: transparent !important; + color: var(--primary-medium) !important; + + .d-icon { + color: var(--primary-medium) !important; + margin: 0; + pointer-events: none; + } + } + + .count { + color: var(--primary); + font-size: $font-up-1; + margin-left: 0.5em; + } +} + +.discourse-reactions-actions { + display: inline-flex; + justify-content: flex-end; + height: 100%; + + .spinner-container { + align-self: center; + align-items: center; + justify-content: center; + display: flex; + margin-left: 0 !important; // core rule has too much specificity + } + + &:not(.custom-reaction-used) { + .discourse-reactions-counter { + line-height: 1; + + + .discourse-reactions-reaction-button { + button { + padding-left: 0.45em; + } + } + } + + .reactions-counter { + padding: 8px 0 8px 10px; + } + } + + &.can-toggle-reaction { + .discourse-reactions-reaction-button .reaction-button, + .discourse-reactions-reaction-button { + cursor: pointer; + } + } + + &:not(.can-toggle-reaction) { + .discourse-reactions-reaction-button .reaction-button, + .discourse-reactions-reaction-button { + cursor: not-allowed; + } + } + + &.has-reacted { + .discourse-no-touch & { + .discourse-reactions-double-button:hover { + background: var(--primary-low); + } + } + + &.has-used-main-reaction { + .discourse-reactions-reaction-button .reaction-button .d-icon { + color: var(--love); + } + } + + &.can-toggle-reaction { + .discourse-no-touch & { + .discourse-reactions-reaction-button:hover .reaction-button { + .d-icon { + color: var(--primary-low-mid); + } + background: var(--primary-low); + } + } + + .discourse-reactions-reaction-button { + cursor: pointer; + } + } + + &:not(.can-toggle-reaction) { + .discourse-no-touch & { + .discourse-reactions-reaction-button:hover .reaction-button { + background: var(--primary-low); + } + } + } + } + + &:not(.has-reacted) { + &.my-post { + .discourse-no-touch & { + .discourse-reactions-double-button:hover { + .d-icon { + color: var(--primary-low-mid); + } + background: var(--primary-low); + } + } + } + + &:not(.my-post) { + .discourse-no-touch & { + .discourse-reactions-double-button:hover, + .discourse-reactions-reaction-button:hover .reaction-button { + background: var(--love-low); + + .d-icon { + color: var(--love); + } + } + } + } + } +} + +.discourse-reactions-double-button { + display: inline-flex; + border-radius: var(--d-post-control-border-radius); + + @include user-select(none); +} + +.discourse-reactions-reaction-button { + position: relative; + display: flex; + touch-action: pan-y; + + .btn-toggle-reaction-like { + outline: none; + pointer-events: none; + background: none; + } + + .btn-toggle-reaction-emoji { + outline: none; + pointer-events: none; + background: none; + width: 1em; + height: 1em; + } +} + +.discourse-reactions-counter { + display: flex; + align-items: center; + text-align: center; + cursor: pointer; + + .reactions-counter { + font-size: $font-up-1; + color: var(--primary-low-mid); + display: flex; + pointer-events: none; + margin-left: 0.5em; + } + + &.only-like { + .reactions-counter { + margin: 0; + } + } +} + +nav.post-controls .actions { + display: inline-flex; +} + +nav.post-controls .actions button { + display: inline-flex; + align-items: center; +} + +.discourse-reactions-my-reaction { + margin-top: 8px; + display: inline-flex; + align-items: center; + + .reaction-emoji { + margin-right: 5px; + height: 15px; + } +} + +nav.post-controls .actions .reaction-button:focus { + background: var(--primary-low); + color: var(--primary); +} + +nav.post-controls .actions .reaction-button { + margin: 0; +} diff --git a/plugins/discourse-reactions/assets/stylesheets/desktop/discourse-reactions.scss b/plugins/discourse-reactions/assets/stylesheets/desktop/discourse-reactions.scss new file mode 100644 index 00000000000..e15c8842a21 --- /dev/null +++ b/plugins/discourse-reactions/assets/stylesheets/desktop/discourse-reactions.scss @@ -0,0 +1,14 @@ +.desktop-view { + .fake-reaction { + position: absolute; + left: 10px; + } + + nav.post-controls .show-replies { + position: relative; + } + + .discourse-reactions-my-reaction { + margin: 0.75em 0 0 3.5em; + } +} diff --git a/plugins/discourse-reactions/assets/stylesheets/mobile/discourse-reactions.scss b/plugins/discourse-reactions/assets/stylesheets/mobile/discourse-reactions.scss new file mode 100644 index 00000000000..e435089f255 --- /dev/null +++ b/plugins/discourse-reactions/assets/stylesheets/mobile/discourse-reactions.scss @@ -0,0 +1,16 @@ +.mobile-view { + .fake-reaction { + position: absolute; + left: 8px; + } + + .discourse-reactions-counter { + margin: 0; + } + + // Fix for the alignment of the reactions summary button in the mobile view when using the Glimmer Post Menu + // This won't ber necessary once the reactions plugin is converted from widgets to Glimmer components + .discourse-reactions-actions-button-shim { + display: inline-flex; + } +} diff --git a/plugins/discourse-reactions/config/locales/client.ar.yml b/plugins/discourse-reactions/config/locales/client.ar.yml new file mode 100644 index 00000000000..5f44bdce9cd --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.ar.yml @@ -0,0 +1,46 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ar: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "تفاعلات Discourse" + js: + discourse_reactions: + reactions_title: "التفاعلات" + state_panel: + more_users: "و%{count} أخرى..." + reaction: + forbidden: "تم إنشاء هذا التفاعل منذ فترة طويلة جدًا، ولم يعُد من الممكن تعديله أو حذفه." + picker: + react_with: "تفاعل مع هذا المنشور باستخدام: %{reaction}" + cant_remove_reaction: "لم يعد بإمكانك إزالة تفاعلك" + remove_reaction: "إزالة هذا التفاعل %{reaction}" + main_reaction: + add: "تسجيل الإعجاب بهذا المنشور" + remove: "إزالة هذا الإعجاب" + cant_remove: "لم يعد بإمكانك إزالة إعجابك" + unauthenticated: "يُرجى الاشتراك أو تسجيل الدخول لتسجيل الإعجاب بهذا المنشور" + notifications: + reaction_1_user_multiple_posts: + zero: "تفاعل على %{count} من منشوراتك" + one: "تفاعل على منشور (%{count}) من منشوراتك" + two: "تفاعل على منشورين (%{count}) من منشوراتك" + few: "تفاعل على %{count} منشورات من منشوراتك" + many: "تفاعل على %{count} منشورًا من منشوراتك" + other: "تفاعل على %{count} منشور من منشوراتك" + reaction_2_users: "%{username}، و%{username2}" + reaction_multiple_users: + zero: "%{username} و%{count} آخر" + one: "%{username} وواحد (%{count}) آخر" + two: "%{username} واثنان (%{count}) آخران" + few: "%{username} و%{count} آخرين" + many: "%{username} و%{count} آخر" + other: "%{username} و%{count} آخر" + titles: + reaction: "تفاعل جديد" diff --git a/plugins/discourse-reactions/config/locales/client.be.yml b/plugins/discourse-reactions/config/locales/client.be.yml new file mode 100644 index 00000000000..386fe6f5278 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.be.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +be: + js: + discourse_reactions: + main_reaction: + add: "Упадабаць гэты пост" diff --git a/plugins/discourse-reactions/config/locales/client.bg.yml b/plugins/discourse-reactions/config/locales/client.bg.yml new file mode 100644 index 00000000000..c40f1e5aaf9 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.bg.yml @@ -0,0 +1,15 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bg: + js: + discourse_reactions: + main_reaction: + add: "Харесайте тази публикация" + notifications: + reaction_2_users: "%{username}, %{username2}" + titles: + reaction: "нова реакция" diff --git a/plugins/discourse-reactions/config/locales/client.bs_BA.yml b/plugins/discourse-reactions/config/locales/client.bs_BA.yml new file mode 100644 index 00000000000..61116fa3402 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.bs_BA.yml @@ -0,0 +1,15 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bs_BA: + js: + discourse_reactions: + state_panel: + more_users: "i %{count} više..." + main_reaction: + add: "Lajkuj ovaj post" + notifications: + reaction_2_users: "%{username}, %{username2}" diff --git a/plugins/discourse-reactions/config/locales/client.ca.yml b/plugins/discourse-reactions/config/locales/client.ca.yml new file mode 100644 index 00000000000..b63854260a4 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.ca.yml @@ -0,0 +1,15 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ca: + js: + discourse_reactions: + state_panel: + more_users: "i %{count} més..." + main_reaction: + add: "M'agrada aquesta publicació" + notifications: + reaction_2_users: "%{username}, %{username2}" diff --git a/plugins/discourse-reactions/config/locales/client.cs.yml b/plugins/discourse-reactions/config/locales/client.cs.yml new file mode 100644 index 00000000000..c7a62e2dac9 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.cs.yml @@ -0,0 +1,42 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +cs: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse Reakce" + js: + discourse_reactions: + reactions_title: "Reakce" + state_panel: + more_users: "a %{count} další(ch)..." + reaction: + forbidden: "Tato reakce byla vytvořena již před příliš dlouhou dobou, nelze ji dále měnit ani odstraňovat." + picker: + react_with: "Reagovat na tento příspěvek pomocí: %{reaction}" + cant_remove_reaction: "Svou reakci již nemůžete odstranit" + remove_reaction: "Odstranit tuto reakci %{reaction}" + main_reaction: + add: "Tento příspěvek se mi líbí" + remove: "Odeberte toto Líbí se" + cant_remove: "Své Líbí se již nelze odebrat" + unauthenticated: "Chcete-li dát Líbí se na tento příspěvek, zaregistrujte se nebo se přihlaste" + notifications: + reaction_1_user_multiple_posts: + one: "reagoval na váš příspěvek" + few: "reagoval na %{count} vaše příspěvky" + many: "reagoval na %{count} vašich příspěvků" + other: "reagoval na %{count} vašich příspěvků" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} a %{count} další" + few: "%{username} a %{count} další" + many: "%{username} a %{count} dalších" + other: "%{username} a %{count} dalších" + titles: + reaction: "nová reakce" diff --git a/plugins/discourse-reactions/config/locales/client.da.yml b/plugins/discourse-reactions/config/locales/client.da.yml new file mode 100644 index 00000000000..45b2def9dc0 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.da.yml @@ -0,0 +1,32 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +da: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse Reaktioner" + js: + discourse_reactions: + reactions_title: "Reaktioner" + state_panel: + more_users: "og %{count} mere..." + reaction: + forbidden: "Den reaktion blev givet for længe siden. Den kan ikke længere ændres eller fjernes." + picker: + react_with: "Reager på dette indlæg med: %{reaction}" + cant_remove_reaction: "Du kan ikke længere fjerne din reaktion" + remove_reaction: "Fjern denne %{reaction} reaktion" + main_reaction: + add: "Du 'synes godt om' dette indlæg" + remove: "Fjern dette 'Synes godt om'" + cant_remove: "Du kan ikke længere fjerne dit 'Synes godt om'" + unauthenticated: "Tilmeld dig eller log ind for at 'synes godt om' dette indlæg" + notifications: + reaction_2_users: "%{username}, %{username2}" + titles: + reaction: "ny reaktion" diff --git a/plugins/discourse-reactions/config/locales/client.de.yml b/plugins/discourse-reactions/config/locales/client.de.yml new file mode 100644 index 00000000000..96d4ef0bd9f --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.de.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +de: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse-Reaktionen" + js: + discourse_reactions: + reactions_title: "Reaktionen" + state_panel: + more_users: "und %{count} weitere …" + reaction: + forbidden: "Diese Reaktion wurde vor zu langer Zeit erstellt. Sie kann nicht mehr bearbeitet oder gelöscht werden." + picker: + react_with: "Reagiere auf diesen Beitrag mit: %{reaction}" + cant_remove_reaction: "Du kannst deine Reaktion nicht mehr entfernen" + remove_reaction: "Entferne diese %{reaction} Reaktion" + main_reaction: + add: "Diesem Beitrag ein „Gefällt mir“ geben" + remove: "Entferne dieses „Gefällt mir“" + cant_remove: "Du kannst dein „Gefällt mir“ nicht mehr entfernen" + unauthenticated: "Bitte registriere dich oder melde dich an, um diesem Beitrag ein „Gefällt mir“ zu geben" + notifications: + reaction_1_user_multiple_posts: + one: "reagierte auf %{count} deiner Beiträge" + other: "reagierte auf %{count} deiner Beiträge" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} und %{count} andere Person" + other: "%{username} und %{count} andere" + titles: + reaction: "neue Reaktion" diff --git a/plugins/discourse-reactions/config/locales/client.el.yml b/plugins/discourse-reactions/config/locales/client.el.yml new file mode 100644 index 00000000000..b942729990a --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.el.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +el: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Αντιδράσεις Discourse" + js: + discourse_reactions: + reactions_title: "Αντιδράσεις" + state_panel: + more_users: "και %{count} ακόμη..." + reaction: + forbidden: "Αυτή η αντίδραση δημιουργήθηκε πολύ καιρό πριν. Δεν μπορεί πια να τροποποιηθεί ή να αφαιρεθεί." + picker: + react_with: "Αντέδρασε σε αυτή την ανάρτηση με: %{reaction}" + cant_remove_reaction: "Δεν μπορείς πια να αφαιρέσεις την αντίδραση σου" + remove_reaction: "Αφαίρεσε την %{reaction} αντίδραση" + main_reaction: + add: "Μου αρέσει η ανάρτηση" + remove: "Αφαίρεσε το \"Μου αρέσει\"" + cant_remove: "Δεν μπορείς πια να αφαιρέσεις το \"Μου αρέσει\"" + unauthenticated: "Παρακαλούμε εγγραφείτε ή συνδεθείτε για να κάνετε \"Μου αρέσει\" στην ανάρτηση" + notifications: + reaction_1_user_multiple_posts: + one: "αντέδρασε σε %{count} από τις αναρτήσεις σας" + other: "αντέδρασε σε %{count} από τις αναρτήσεις σας" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} και %{count} ακόμη" + other: "%{username} και %{count} ακόμη" + titles: + reaction: "νέα αντίδραση" diff --git a/plugins/discourse-reactions/config/locales/client.en.yml b/plugins/discourse-reactions/config/locales/client.en.yml new file mode 100644 index 00000000000..6ebca568fb5 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.en.yml @@ -0,0 +1,32 @@ +en: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse Reactions" + js: + discourse_reactions: + reactions_title: "Reactions" + state_panel: + more_users: "and %{count} more..." + reaction: + forbidden: "That reaction was created too long ago. It can no longer be modified or removed." + picker: + react_with: "React to this post with: %{reaction}" + cant_remove_reaction: "You can no longer remove your reaction" + remove_reaction: "Remove this %{reaction} reaction" + main_reaction: + add: "Like this post" + remove: "Remove this like" + cant_remove: "You can no longer remove your like" + unauthenticated: "Please sign up or log in to like this post" + notifications: + reaction_1_user_multiple_posts: + one: "reacted to %{count} of your post" + other: "reacted to %{count} of your posts" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} and %{count} other" + other: "%{username} and %{count} others" + titles: + reaction: "new reaction" diff --git a/plugins/discourse-reactions/config/locales/client.en_GB.yml b/plugins/discourse-reactions/config/locales/client.en_GB.yml new file mode 100644 index 00000000000..2d4fa180ec7 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.en_GB.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +en_GB: diff --git a/plugins/discourse-reactions/config/locales/client.es.yml b/plugins/discourse-reactions/config/locales/client.es.yml new file mode 100644 index 00000000000..32a7a29c15b --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.es.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +es: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Reacciones de Discourse" + js: + discourse_reactions: + reactions_title: "Reacciones" + state_panel: + more_users: "y %{count} más..." + reaction: + forbidden: "Esa reacción se creó hace demasiado tiempo. Ya no se puede modificar ni eliminar." + picker: + react_with: "Reacciona a esta publicación con: %{reaction}" + cant_remove_reaction: "Ya no puedes eliminar tu reacción" + remove_reaction: "Eliminar la reacción %{reaction}" + main_reaction: + add: "Me gusta esta publicación" + remove: "Deshacer me gusta" + cant_remove: "Ya no puedes eliminar tu me gusta" + unauthenticated: "Regístrate o inicia sesión para darle me gusta a esta publicación" + notifications: + reaction_1_user_multiple_posts: + one: "reaccionó a %{count} de tu publicación" + other: "reaccionó a %{count} de tus publicaciones" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} y %{count} más" + other: "%{username} y otros %{count}" + titles: + reaction: "nueva reacción" diff --git a/plugins/discourse-reactions/config/locales/client.et.yml b/plugins/discourse-reactions/config/locales/client.et.yml new file mode 100644 index 00000000000..edae86c7547 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.et.yml @@ -0,0 +1,13 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +et: + js: + discourse_reactions: + main_reaction: + add: "Laigi seda postitust" + notifications: + reaction_2_users: "%{username}, %{username2}" diff --git a/plugins/discourse-reactions/config/locales/client.fa_IR.yml b/plugins/discourse-reactions/config/locales/client.fa_IR.yml new file mode 100644 index 00000000000..b18eb35aeea --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.fa_IR.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fa_IR: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "واکنش‌های دیسکورس" + js: + discourse_reactions: + reactions_title: "واکنش‌ها" + state_panel: + more_users: "و %{count} نفر دیگر..." + reaction: + forbidden: "این واکنش خیلی وقت پیش ایجاد شد. دیگر نمی‌توان آن را اصلاح یا حذف کرد." + picker: + react_with: "واکنش به این نوشته با: %{reaction}" + cant_remove_reaction: "شما دیگر واکنش خود را نمی‌توانید حذف کنید" + remove_reaction: "حذف این %{reaction} واکنش" + main_reaction: + add: "پسندیدن این نوشته" + remove: "حذف واکنش پسندیدن" + cant_remove: "شما دیگر پسندیدن خود را نمی‌توانید حذف کنید" + notifications: + reaction_2_users: "%{username}، %{username2}" + reaction_multiple_users: + one: "%{username} و %{count} نفر دیگر" + other: "%{username} و %{count} نفر دیگر" + titles: + reaction: "واکنش‌ جدید" diff --git a/plugins/discourse-reactions/config/locales/client.fi.yml b/plugins/discourse-reactions/config/locales/client.fi.yml new file mode 100644 index 00000000000..8277a4a390e --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.fi.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fi: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse Reactions" + js: + discourse_reactions: + reactions_title: "Reaktiot" + state_panel: + more_users: "ja %{count} muuta..." + reaction: + forbidden: "Tämä reaktio luotiin liian kauan sitten. Sitä ei voi enää muokata tai poistaa." + picker: + react_with: "Reagoi tähän viestiin reaktiolla: %{reaction}" + cant_remove_reaction: "Et voi enää poistaa reaktiotasi" + remove_reaction: "Poista tämä reaktio: %{reaction}" + main_reaction: + add: "Tykkää viestistä" + remove: "Poista tämä tykkäys" + cant_remove: "Et voi enää poistaa tykkäystäsi" + unauthenticated: "Tykkää julkaisusta rekisteröitymällä tai kirjautumalla sisään" + notifications: + reaction_1_user_multiple_posts: + one: "reagoi %{count} viestiisi" + other: "reagoi %{count} viestiisi" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} ja %{count} muu" + other: "%{username} ja %{count} muuta" + titles: + reaction: "uusi reaktio" diff --git a/plugins/discourse-reactions/config/locales/client.fr.yml b/plugins/discourse-reactions/config/locales/client.fr.yml new file mode 100644 index 00000000000..cfab11e73f7 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.fr.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fr: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Réactions de Discourse" + js: + discourse_reactions: + reactions_title: "Réactions" + state_panel: + more_users: "et %{count} de plus…" + reaction: + forbidden: "Cette réaction a été créée il y a trop longtemps. Elle ne peut plus être modifiée ou supprimée." + picker: + react_with: "Réagir à ce message avec : %{reaction}" + cant_remove_reaction: "Vous ne pouvez plus supprimer votre réaction" + remove_reaction: "Supprimer cette réaction %{reaction}" + main_reaction: + add: "Attribuer un « J'aime » à ce message" + remove: "Supprimer ce « J'aime »" + cant_remove: "Vous ne pouvez plus supprimer votre J'aime" + unauthenticated: "Veuillez vous inscrire ou vous connecter pour aimer cet article" + notifications: + reaction_1_user_multiple_posts: + one: "a réagi à %{count} de vos messages" + other: "a réagi à %{count} de vos messages" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} et %{count} autre utilisateur" + other: "%{username} et %{count} autres utilisateurs" + titles: + reaction: "nouvelle réaction" diff --git a/plugins/discourse-reactions/config/locales/client.gl.yml b/plugins/discourse-reactions/config/locales/client.gl.yml new file mode 100644 index 00000000000..ad3e353e2e2 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.gl.yml @@ -0,0 +1,17 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +gl: + js: + discourse_reactions: + state_panel: + more_users: "e %{count} máis..." + main_reaction: + add: "Gústame esta publicación" + notifications: + reaction_2_users: "%{username}, %{username2}" + titles: + reaction: "nova reacción" diff --git a/plugins/discourse-reactions/config/locales/client.he.yml b/plugins/discourse-reactions/config/locales/client.he.yml new file mode 100644 index 00000000000..225242b8fb9 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.he.yml @@ -0,0 +1,42 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +he: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "תחושות ב־Discourse" + js: + discourse_reactions: + reactions_title: "תחושות" + state_panel: + more_users: "ו־%{count} נוספות..." + reaction: + forbidden: "תחושה זו נוצרה לפני זמן רב מידי. לא ניתן יותר לערוך או להסיר אותה." + picker: + react_with: "להוסיף תחושה לפוסט הזה: %{reaction}" + cant_remove_reaction: "כבר אין לך אפשרות להסיר את התחושה שלך" + remove_reaction: "להסיר את התחושה %{reaction}" + main_reaction: + add: "לסמן את הפוסט בלייק" + remove: "הסרת הלייק הזה" + cant_remove: "כבר אין לך אפשרות להסיר את הלייק שלך" + unauthenticated: "נא להירשם או להיכנס כדי לסמן את הפוסט הזה בלייק" + notifications: + reaction_1_user_multiple_posts: + one: "תגובה כלשהי לפוסט שלך" + two: "תגובה כלשהי ל־%{count} מהפוסטים שלך" + many: "תגובה כלשהי ל־%{count} מהפוסטים שלך" + other: "תגובה כלשהי ל־%{count} מהפוסטים שלך" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} ועוד %{count}" + two: "%{username} ועוד %{count}" + many: "%{username} ועוד %{count}" + other: "%{username} ועוד %{count}" + titles: + reaction: "תחושה חדשה" diff --git a/plugins/discourse-reactions/config/locales/client.hr.yml b/plugins/discourse-reactions/config/locales/client.hr.yml new file mode 100644 index 00000000000..dc1862bc36c --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.hr.yml @@ -0,0 +1,21 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hr: + js: + discourse_reactions: + state_panel: + more_users: "i %{count} više..." + main_reaction: + add: "Like-aj objavu" + notifications: + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} i %{count} drugi" + few: "%{username} i %{count} druga" + other: "%{username} i %{count} drugih" + titles: + reaction: "nova reakcija" diff --git a/plugins/discourse-reactions/config/locales/client.hu.yml b/plugins/discourse-reactions/config/locales/client.hu.yml new file mode 100644 index 00000000000..9b6653b4fdf --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.hu.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hu: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse reakciók" + js: + discourse_reactions: + reactions_title: "Reakciók" + state_panel: + more_users: "és %{count} további…" + reaction: + forbidden: "Ez a reakció túl régen lett létrehozva. Már nem lehet módosítani vagy eltávolítani." + picker: + react_with: "Reagálás erre a bejegyzésre ezzel: %{reaction}" + cant_remove_reaction: "Nem lehet eltávolítani a reakciót" + remove_reaction: "A(z) %{reaction} reakció eltávolítása" + main_reaction: + add: "Bejegyzés kedvelése" + remove: "Kedvelés eltávolítása" + cant_remove: "Kedvelés nem távolítható el" + unauthenticated: "Kérjük, regisztráljon vagy jelentkezzen be, hogy kedvelni tudja ezt a bejegyzést" + notifications: + reaction_1_user_multiple_posts: + one: "%{count} reakciót váltott ki a bejegyzés" + other: "%{count} reakciót váltottak ki a bejegyzések" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} és még %{count} fő" + other: "%{username} és még %{count} fő" + titles: + reaction: "új reakció" diff --git a/plugins/discourse-reactions/config/locales/client.hy.yml b/plugins/discourse-reactions/config/locales/client.hy.yml new file mode 100644 index 00000000000..949f16f5dca --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.hy.yml @@ -0,0 +1,13 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hy: + js: + discourse_reactions: + main_reaction: + add: "Հավանել այս գրառումը" + notifications: + reaction_2_users: "%{username}, %{username2}" diff --git a/plugins/discourse-reactions/config/locales/client.id.yml b/plugins/discourse-reactions/config/locales/client.id.yml new file mode 100644 index 00000000000..61597981296 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.id.yml @@ -0,0 +1,14 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +id: + js: + discourse_reactions: + main_reaction: + add: "Sukai post ini" + notifications: + reaction_multiple_users: + other: "%{username} dan %{count} lainnya" diff --git a/plugins/discourse-reactions/config/locales/client.it.yml b/plugins/discourse-reactions/config/locales/client.it.yml new file mode 100644 index 00000000000..921916da42b --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.it.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +it: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Reazioni Discourse" + js: + discourse_reactions: + reactions_title: "Reazioni" + state_panel: + more_users: "e altre %{count}..." + reaction: + forbidden: "Quella reazione è stata creata troppo tempo fa. Non può più essere modificata né rimossa." + picker: + react_with: "Reagisci a questo messaggio con: %{reaction}" + cant_remove_reaction: "Non puoi più rimuovere la tua reazione" + remove_reaction: "Rimuovi questa reazione %{reaction}" + main_reaction: + add: "Metti \"Mi piace\" a questo messaggio" + remove: "Rimuovi questo \"Mi piace\"" + cant_remove: "Non puoi più rimuovere il tuo Mi piace" + unauthenticated: "Registrati o accedi per mettere mi piace a questo post" + notifications: + reaction_1_user_multiple_posts: + one: "ha reagito a %{count} tuo messaggio" + other: "ha reagito a %{count} dei tuoi messaggi" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} e %{count} altro" + other: "%{username} e altri %{count}" + titles: + reaction: "nuova reazione" diff --git a/plugins/discourse-reactions/config/locales/client.ja.yml b/plugins/discourse-reactions/config/locales/client.ja.yml new file mode 100644 index 00000000000..276859b98bf --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.ja.yml @@ -0,0 +1,36 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ja: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse リアクション" + js: + discourse_reactions: + reactions_title: "リアクション" + state_panel: + more_users: "および他 %{count} 人..." + reaction: + forbidden: "そのリアクションはかなり前に作成されました。変更または削除できなくなっています。" + picker: + react_with: "この投稿へのリアクション: %{reaction}" + cant_remove_reaction: "リアクションを削除できなくなりました" + remove_reaction: "この %{reaction} リアクションを削除する" + main_reaction: + add: "この投稿に「いいね!」する" + remove: "この「いいね!」を削除する" + cant_remove: "「いいね!」を削除できなくなりました" + unauthenticated: "この投稿に「いいね!」するには登録またはログインしてください" + notifications: + reaction_1_user_multiple_posts: + other: "があなたの %{count} 件の投稿にリアクションしました" + reaction_2_users: "%{username}、%{username2}" + reaction_multiple_users: + other: "%{username} および他 %{count} 人" + titles: + reaction: "新しいリアクション" diff --git a/plugins/discourse-reactions/config/locales/client.ko.yml b/plugins/discourse-reactions/config/locales/client.ko.yml new file mode 100644 index 00000000000..e6e2e204314 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.ko.yml @@ -0,0 +1,17 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ko: + js: + discourse_reactions: + state_panel: + more_users: "그리고 %{count} 더..." + main_reaction: + add: "좋아요" + notifications: + reaction_2_users: "%{username}, %{username2}" + titles: + reaction: "새로운 반응" diff --git a/plugins/discourse-reactions/config/locales/client.lt.yml b/plugins/discourse-reactions/config/locales/client.lt.yml new file mode 100644 index 00000000000..f5d442e96e4 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.lt.yml @@ -0,0 +1,17 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lt: + js: + discourse_reactions: + state_panel: + more_users: "ir %{count} daugiau..." + main_reaction: + add: "Mėgti šį įrašą" + notifications: + reaction_2_users: "%{username}, %{username2}" + titles: + reaction: "nauja reakcija" diff --git a/plugins/discourse-reactions/config/locales/client.lv.yml b/plugins/discourse-reactions/config/locales/client.lv.yml new file mode 100644 index 00000000000..59e0ef6f4ed --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.lv.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lv: diff --git a/plugins/discourse-reactions/config/locales/client.nb_NO.yml b/plugins/discourse-reactions/config/locales/client.nb_NO.yml new file mode 100644 index 00000000000..d534267b3e4 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.nb_NO.yml @@ -0,0 +1,15 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nb_NO: + js: + discourse_reactions: + main_reaction: + add: "Liker dette innlegget" + notifications: + reaction_2_users: "%{username}, %{username2}" + titles: + reaction: "ny reaksjon" diff --git a/plugins/discourse-reactions/config/locales/client.nl.yml b/plugins/discourse-reactions/config/locales/client.nl.yml new file mode 100644 index 00000000000..894e932fa69 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.nl.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nl: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse-reacties" + js: + discourse_reactions: + reactions_title: "Reacties" + state_panel: + more_users: "en nog %{count}..." + reaction: + forbidden: "De reactie is te lang geleden gemaakt. Deze kan niet meer worden gewijzigd of verwijderd." + picker: + react_with: "Reageer op dit bericht met: %{reaction}" + cant_remove_reaction: "Je kunt je reactie niet meer verwijderen" + remove_reaction: "Deze %{reaction} reactie verwijderen" + main_reaction: + add: "Dit bericht liken" + remove: "Deze like verwijderen" + cant_remove: "Je kunt je like niet meer verwijderen" + unauthenticated: "Registreer of meld je aan om dit bericht te liken" + notifications: + reaction_1_user_multiple_posts: + one: "heeft gereageerd op %{count} van je berichten" + other: "heeft gereageerd op %{count} van je berichten" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} en %{count} ander" + other: "%{username} en %{count} anderen" + titles: + reaction: "nieuwe reactie" diff --git a/plugins/discourse-reactions/config/locales/client.pl_PL.yml b/plugins/discourse-reactions/config/locales/client.pl_PL.yml new file mode 100644 index 00000000000..99e8dbd720e --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.pl_PL.yml @@ -0,0 +1,42 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pl_PL: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Reakcje Discourse" + js: + discourse_reactions: + reactions_title: "Reakcje" + state_panel: + more_users: "i %{count} więcej..." + reaction: + forbidden: "Ta reakcja powstała zbyt dawno temu. Nie można go już modyfikować ani usuwać." + picker: + react_with: "Zareaguj na ten post z: %{reaction}" + cant_remove_reaction: "Nie możesz już usunąć swojej reakcji" + remove_reaction: "Usuń reakcję %{reaction}" + main_reaction: + add: "Polub ten post" + remove: "Usuń to polubienie" + cant_remove: "Nie możesz już usunąć polubienia" + unauthenticated: "Zarejestruj się lub zaloguj, aby polubić ten post" + notifications: + reaction_1_user_multiple_posts: + one: "zareagował na %{count} Twój post" + few: "zareagował na %{count} Twoje posty" + many: "zareagował na %{count} Twoich postów" + other: "zareagował na %{count} Twoich postów" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} i %{count} inny" + few: "%{username} i %{count} inni" + many: "%{username} i %{count} innych" + other: "%{username} i %{count} innych" + titles: + reaction: "nowa reakcja" diff --git a/plugins/discourse-reactions/config/locales/client.pt.yml b/plugins/discourse-reactions/config/locales/client.pt.yml new file mode 100644 index 00000000000..bf69c70ba79 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.pt.yml @@ -0,0 +1,27 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Reações Discourse" + js: + discourse_reactions: + reactions_title: "Reações" + state_panel: + more_users: "e mais %{count}..." + picker: + react_with: "Reagir a esta publicação com: %{reaction}" + remove_reaction: "Remover esta %{reaction} reação" + main_reaction: + add: "Gostar desta publicação" + remove: "Remover este gosto" + notifications: + reaction_2_users: "%{username}, %{username2}" + titles: + reaction: "nova reação" diff --git a/plugins/discourse-reactions/config/locales/client.pt_BR.yml b/plugins/discourse-reactions/config/locales/client.pt_BR.yml new file mode 100644 index 00000000000..6e5113bf4e5 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.pt_BR.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt_BR: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Reações do Discourse" + js: + discourse_reactions: + reactions_title: "Reações" + state_panel: + more_users: "e mais %{count}..." + reaction: + forbidden: "Essa reação foi criada há muito tempo. Ela não pode mais ser modificada ou removida." + picker: + react_with: "Reaja a esta postagem com: %{reaction}" + cant_remove_reaction: "Você não pode mais remover sua reação" + remove_reaction: "Remova esta %{reaction} reação" + main_reaction: + add: "Curta esta postagem" + remove: "Remova essa curtida" + cant_remove: "Você não pode mais remover sua curtida" + unauthenticated: "Cadastre-se ou faça login para curtir esta postagem" + notifications: + reaction_1_user_multiple_posts: + one: "reagiu a %{count} da sua postagem" + other: "reagiu a %{count} das suas postagens" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} e %{count} outro" + other: "%{username} e %{count} outros(as)" + titles: + reaction: "nova reação" diff --git a/plugins/discourse-reactions/config/locales/client.ro.yml b/plugins/discourse-reactions/config/locales/client.ro.yml new file mode 100644 index 00000000000..9900b42a077 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.ro.yml @@ -0,0 +1,40 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ro: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Reacții Discourse" + js: + discourse_reactions: + reactions_title: "Reacții" + state_panel: + more_users: "și încă %{count}..." + reaction: + forbidden: "Acea reacție a fost creată cu prea mult timp în urmă. Nu mai poate fi modificată sau eliminată." + picker: + react_with: "Reacționează la acest articol cu: %{reaction}" + cant_remove_reaction: "Nu-ți mai poți elimina reacția." + remove_reaction: "Elimină această reacție %{reaction}" + main_reaction: + add: "Apreciază acest articol" + remove: "Elimină această apreciere" + cant_remove: "Nu-ți mai poți elimina aprecierea" + unauthenticated: "Te rugăm să te înregistrezi sau să te conectezi pentru a aprecia acest articol." + notifications: + reaction_1_user_multiple_posts: + one: "a reacționat la %{count} din articolele tale" + few: "a reacționat la %{count} din articolele tale" + other: "a reacționat la %{count} din articolele tale" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} și încă %{count}" + few: "%{username} și încă %{count}" + other: "%{username} și încă %{count}" + titles: + reaction: "reacție nouă" diff --git a/plugins/discourse-reactions/config/locales/client.ru.yml b/plugins/discourse-reactions/config/locales/client.ru.yml new file mode 100644 index 00000000000..465c4342d4a --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.ru.yml @@ -0,0 +1,42 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ru: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Реакции" + js: + discourse_reactions: + reactions_title: "Реакции" + state_panel: + more_users: "и еще %{count}..." + reaction: + forbidden: "Эту реакцию нельзя изменить или удалить - она была создана очень давно." + picker: + react_with: "Отреагировать на это сообщение: %{reaction}" + cant_remove_reaction: "Вы больше не можете удалить свою реакцию" + remove_reaction: "Удалить реакцию %{reaction}" + main_reaction: + add: "Выразить своё отношение к этому сообщению" + remove: "Удалить эту эмоцию" + cant_remove: "Вы больше не можете удалить поставленный лайк" + unauthenticated: "Чтобы поставить лайк этой публикации, зарегистрируйтесь или войдите" + notifications: + reaction_1_user_multiple_posts: + one: "отреагировал(-а) на %{count} вашу публикацию" + few: "отреагировал(-а) на %{count} ваши публикации" + many: "отреагировал(-а) на %{count} ваших публикаций" + other: "отреагировал(-а) на ваши публикации (%{count})" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} и ещё %{count}" + few: "%{username} и ещё %{count}" + many: "%{username} и ещё %{count}" + other: "%{username} и ещё %{count}" + titles: + reaction: "Новая реакция" diff --git a/plugins/discourse-reactions/config/locales/client.sk.yml b/plugins/discourse-reactions/config/locales/client.sk.yml new file mode 100644 index 00000000000..999537c62c0 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.sk.yml @@ -0,0 +1,22 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sk: + js: + discourse_reactions: + state_panel: + more_users: "a %{count} ďalšie..." + main_reaction: + add: "Páči sa mi tento príspevok" + notifications: + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} a %{count} ďalší" + few: "%{username} a %{count} ďalších" + many: "%{username} a %{count} ďalších" + other: "%{username} a %{count} ďalších" + titles: + reaction: "nová reakcia" diff --git a/plugins/discourse-reactions/config/locales/client.sl.yml b/plugins/discourse-reactions/config/locales/client.sl.yml new file mode 100644 index 00000000000..4c5b337bdd9 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.sl.yml @@ -0,0 +1,10 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sl: + js: + notifications: + reaction_2_users: "%{username}, %{username2}" diff --git a/plugins/discourse-reactions/config/locales/client.sq.yml b/plugins/discourse-reactions/config/locales/client.sq.yml new file mode 100644 index 00000000000..84cbcb6a468 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.sq.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sq: + js: + discourse_reactions: + main_reaction: + add: "Më pëlqen ky postim" diff --git a/plugins/discourse-reactions/config/locales/client.sr.yml b/plugins/discourse-reactions/config/locales/client.sr.yml new file mode 100644 index 00000000000..88d63d6ae1a --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.sr.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sr: diff --git a/plugins/discourse-reactions/config/locales/client.sv.yml b/plugins/discourse-reactions/config/locales/client.sv.yml new file mode 100644 index 00000000000..cd6976a1b71 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.sv.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sv: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse Reaktioner" + js: + discourse_reactions: + reactions_title: "Reaktioner" + state_panel: + more_users: "och %{count} till..." + reaction: + forbidden: "Den reaktionen skapades för långt tillbaka i tiden. Den kan inte längre ändras eller tas bort." + picker: + react_with: "Reagera på det här inlägget med: %{reaction}" + cant_remove_reaction: "Du kan inte längre ta bort din reaktion" + remove_reaction: "Ta bort denna %{reaction}-reaktion" + main_reaction: + add: "Gilla detta inlägg" + remove: "Ta bort denna gillning" + cant_remove: "Du kan inte längre ta bort ditt gillande" + notifications: + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} och %{count} andra" + other: "%{username} och %{count} andra" + titles: + reaction: "ny reaktion" diff --git a/plugins/discourse-reactions/config/locales/client.sw.yml b/plugins/discourse-reactions/config/locales/client.sw.yml new file mode 100644 index 00000000000..2ac85e84ae6 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.sw.yml @@ -0,0 +1,13 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sw: + js: + discourse_reactions: + main_reaction: + add: "Penda chapisho hili" + notifications: + reaction_2_users: "%{username}, %{username2}" diff --git a/plugins/discourse-reactions/config/locales/client.te.yml b/plugins/discourse-reactions/config/locales/client.te.yml new file mode 100644 index 00000000000..0ba1d360bfe --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.te.yml @@ -0,0 +1,18 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +te: + js: + discourse_reactions: + main_reaction: + add: "ఈ టపాను ఇష్టపడు" + notifications: + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} మరియు %{count} ఇతర" + other: "%{username} మరియు %{count} ఇతరులు" + titles: + reaction: "కొత్త స్పందన" diff --git a/plugins/discourse-reactions/config/locales/client.th.yml b/plugins/discourse-reactions/config/locales/client.th.yml new file mode 100644 index 00000000000..7de85ff91c4 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.th.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +th: diff --git a/plugins/discourse-reactions/config/locales/client.tr_TR.yml b/plugins/discourse-reactions/config/locales/client.tr_TR.yml new file mode 100644 index 00000000000..3b1c751d513 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.tr_TR.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +tr_TR: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse Tepkileri" + js: + discourse_reactions: + reactions_title: "Tepkiler" + state_panel: + more_users: "ve %{count} tane daha..." + reaction: + forbidden: "Bu tepki çok uzun zaman önce oluşturuldu. Artık değiştirilemez veya kaldırılamaz." + picker: + react_with: "Bu gönderiye şununla tepki verin: %{reaction}" + cant_remove_reaction: "Artık tepkinizi kaldıramazsınız" + remove_reaction: "Bu %{reaction} tepkisini kaldır" + main_reaction: + add: "Bu gönderiyi beğen" + remove: "Bu beğeniyi kaldır" + cant_remove: "Artık beğeninizi kaldıramazsınız" + unauthenticated: "Bu gönderiyi beğenmek için lütfen kaydolun veya giriş yapın" + notifications: + reaction_1_user_multiple_posts: + one: "%{count} gönderinize tepki verdi" + other: "%{count} gönderinize tepki verdi" + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} ve %{count} kişi daha" + other: "%{username} ve %{count} kişi daha" + titles: + reaction: "yeni tepki" diff --git a/plugins/discourse-reactions/config/locales/client.ug.yml b/plugins/discourse-reactions/config/locales/client.ug.yml new file mode 100644 index 00000000000..a3af416fd2b --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.ug.yml @@ -0,0 +1,20 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ug: + js: + discourse_reactions: + state_panel: + more_users: "ۋە باشقا %{count}..." + main_reaction: + add: "بۇ يازمىنى ياقتۇر" + notifications: + reaction_2_users: "%{username}، %{username2}" + reaction_multiple_users: + one: "%{username} ۋە باشقا %{count} كىشى" + other: "%{username} ۋە باشقا %{count} كىشى" + titles: + reaction: "يېڭى ئىنكاس" diff --git a/plugins/discourse-reactions/config/locales/client.uk.yml b/plugins/discourse-reactions/config/locales/client.uk.yml new file mode 100644 index 00000000000..9b9c05c7e68 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.uk.yml @@ -0,0 +1,22 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +uk: + js: + discourse_reactions: + state_panel: + more_users: "і більше %{count}..." + main_reaction: + add: "Мені подобається цей допис" + notifications: + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + one: "%{username} і ще %{count}" + few: "%{username} і ще %{count}" + many: "%{username} і ще %{count}" + other: "%{username} і ще %{count}" + titles: + reaction: "нова реакція" diff --git a/plugins/discourse-reactions/config/locales/client.ur.yml b/plugins/discourse-reactions/config/locales/client.ur.yml new file mode 100644 index 00000000000..ddcac5be4f8 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.ur.yml @@ -0,0 +1,15 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ur: + js: + discourse_reactions: + main_reaction: + add: "اس پوسٹ کو پسند کریں" + notifications: + reaction_2_users: "%{username}, %{username2}" + titles: + reaction: "نیا ردعمل" diff --git a/plugins/discourse-reactions/config/locales/client.vi.yml b/plugins/discourse-reactions/config/locales/client.vi.yml new file mode 100644 index 00000000000..c54522b3b81 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.vi.yml @@ -0,0 +1,19 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +vi: + js: + discourse_reactions: + state_panel: + more_users: "và %{count} nữa..." + main_reaction: + add: "Thích bài viết này" + notifications: + reaction_2_users: "%{username}, %{username2}" + reaction_multiple_users: + other: "%{username} và %{count} khác" + titles: + reaction: "phản ứng mới" diff --git a/plugins/discourse-reactions/config/locales/client.zh_CN.yml b/plugins/discourse-reactions/config/locales/client.zh_CN.yml new file mode 100644 index 00000000000..259fc7c021c --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.zh_CN.yml @@ -0,0 +1,36 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_CN: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse Reactions" + js: + discourse_reactions: + reactions_title: "回应" + state_panel: + more_users: "还有 %{count} 个…" + reaction: + forbidden: "该回应是很久以前创建的。它不能再被修改或移除。" + picker: + react_with: "使用以下方式回应此帖子:%{reaction}" + cant_remove_reaction: "您无法再移除您自己的回应了" + remove_reaction: "删除此 %{reaction} 回应" + main_reaction: + add: "点赞此帖子" + remove: "移除此赞" + cant_remove: "您无法再移除您自己的赞了" + unauthenticated: "请注册或登录以点赞此帖子" + notifications: + reaction_1_user_multiple_posts: + other: "回应了您的 %{count} 个帖子" + reaction_2_users: "%{username}、%{username2}" + reaction_multiple_users: + other: "%{username} 和其他 %{count} 人" + titles: + reaction: "新回应" diff --git a/plugins/discourse-reactions/config/locales/client.zh_TW.yml b/plugins/discourse-reactions/config/locales/client.zh_TW.yml new file mode 100644 index 00000000000..515690e48e3 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/client.zh_TW.yml @@ -0,0 +1,28 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_TW: + admin_js: + admin: + site_settings: + categories: + discourse_reactions: "Discourse Reactions" + js: + discourse_reactions: + reactions_title: "反應" + state_panel: + more_users: "和%{count}更多。" + picker: + react_with: "對這篇文章的反應是: %{reaction}" + cant_remove_reaction: "您無法再移除您的反應" + remove_reaction: "刪除此 %{reaction} 反應" + main_reaction: + add: "喜歡這篇文章" + remove: "刪除這個喜歡" + notifications: + reaction_2_users: "%{username}、%{username2}" + titles: + reaction: "新的反應" diff --git a/plugins/discourse-reactions/config/locales/server.ar.yml b/plugins/discourse-reactions/config/locales/server.ar.yml new file mode 100644 index 00000000000..bf570eb89ca --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.ar.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ar: + site_settings: + discourse_reactions_like_icon: "يحدِّد الأيقونة المُستخدَمة لزر التفاعل الرئيسي. ينبغي أن يكون هذا اسم أيقونة من Font Awesome، وليس اسم رمز تعبيري." + discourse_reactions_reaction_for_like: "يحدِّد التفاعل المرتبط بالإجراء \"أعجبني\". لن يتم تغيير سجلات الإعجاب التاريخية إذا تغيَّر هذا الإعداد، فمن الموصى به بشدة عدم تغيير هذا الإعداد أبدًا." + discourse_reactions_enabled: "يفعِّل المكوِّن الإضافي discourse-reactions" + discourse_reactions_enabled_reactions: "يحدد قائمة بالتفاعلات المفعَّلة، ويُسمَح بأي رمز تعبيري هنا." + discourse_reactions_desaturated_reaction_panel: "يقلل التشويش البصري للتفاعلات عن طريق عرضها بألوان غير مشبَّعة حتى التمرير فوقها." + discourse_reactions_excluded_from_like: "التفاعلات التي لا تُحتسَب كتسجيل إعجاب. سيتم احتساب أي تفاعلات غير موجودة في هذه القائمة كتسجيل إعجاب للشارات والبلاغات وأغراضٍ أخرى." + discourse_reactions_like_sync_enabled: "إذا تم تفعيل هذا الإعداد، فسيتم إنشاء سجلات الإعجاب المطابقة للتفاعلات التاريخية، باستثناء تلك التفاعلات المحدَّدة في `discourse_reactions_excluded_from_like`. ستحدث هذه المزامنة بشكلٍ منتظم في الخلفية، وأيضًا عند تغيير `discourse_reactions_excluded_from_like`." + errors: + invalid_excluded_emoji: "لا يمكنك استبعاد الرموز التعبيرية التي ليست ضمن 'discourse reactions enabled reactions' ولا يمكنك استبعاد الرموز التعبيرية المستخدمة في 'discourse reactions reaction for like'." + badges: + first_reaction: + name: أول تفاعل + description: تفاعل مع المنشور + long_description: | + يتم منح هذه الشارة في المرة الأولى التي تتفاعل فيها مع منشور. يُعد التفاعل مع المنشورات طريقة رائعة لإخبار زملائك في المجتمع بأن ما نشروه كان مثيرًا للاهتمام أو مفيدًا أو رائعًا أو مرحًا. شارك تفاعلك! + reports: + reactions: + title: "التفاعلات" + description: "يعرض قائمة بأحدث التفاعلات." + labels: + day: "اليوم" diff --git a/plugins/discourse-reactions/config/locales/server.be.yml b/plugins/discourse-reactions/config/locales/server.be.yml new file mode 100644 index 00000000000..259b98e3029 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.be.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +be: + reports: + reactions: + labels: + day: "дзень" diff --git a/plugins/discourse-reactions/config/locales/server.bg.yml b/plugins/discourse-reactions/config/locales/server.bg.yml new file mode 100644 index 00000000000..30bc68a74f4 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.bg.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bg: + reports: + reactions: + labels: + day: "Ден" diff --git a/plugins/discourse-reactions/config/locales/server.bs_BA.yml b/plugins/discourse-reactions/config/locales/server.bs_BA.yml new file mode 100644 index 00000000000..be88baa971a --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.bs_BA.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bs_BA: + reports: + reactions: + labels: + day: "Day" diff --git a/plugins/discourse-reactions/config/locales/server.ca.yml b/plugins/discourse-reactions/config/locales/server.ca.yml new file mode 100644 index 00000000000..b92c2da5e7f --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.ca.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ca: + reports: + reactions: + labels: + day: "Dia" diff --git a/plugins/discourse-reactions/config/locales/server.cs.yml b/plugins/discourse-reactions/config/locales/server.cs.yml new file mode 100644 index 00000000000..2865aabf6ce --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.cs.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +cs: + site_settings: + discourse_reactions_like_icon: "Definuje ikonu použitou pro hlavní reakční tlačítko. Mělo by se jednat o název ikony Font Awesome, nikoli o název emoji." + discourse_reactions_reaction_for_like: "Definuje reakci přidruženou k akci Líbí se. Pokud změníte toto nastavení, nebude to mít vliv na historické záznamy a důrazně doporučujeme toto nastavení nikdy neměnit." + discourse_reactions_enabled: "Povolit plugin Discourse-Reakce." + discourse_reactions_enabled_reactions: "Určuje seznam povolených reakcí, všechny emoji jsou zde povoleny." + discourse_reactions_desaturated_reaction_panel: "Snižuje vizuální šum reakcí tím, že je zobrazí až po najetí myší." + discourse_reactions_excluded_from_like: "Reakce, které se nepočítají jako \"Líbí se\". Reakce, které nejsou na tomto seznamu, se budou počítat jako \"Líbí se\" pro účely odznaků, reporting a další účely." + discourse_reactions_like_sync_enabled: "Pokud je toto povoleno, budou mít historické reakce vytvořeny odpovídající záznamy Líbí se, kromě reakcí definovaných v `discourse_reactions_excluded_from_like`. Tato synchronizace bude probíhat pravidelně na pozadí a také když změníte `discourse_reactions_excluded_from_like`." + errors: + invalid_excluded_emoji: "Nemůžete vyloučit emotikony, které nejsou v 'discourse reactions enabled reactions' a nemůžete vyloučit emotikony používané pro 'discourse reactions reaction for like'." + badges: + first_reaction: + name: První reakce + description: Reagoval/a na příspěvek + long_description: | + Tento odznak je udělen při první reakci na příspěvek. Reakce na příspěvky je skvělý způsob, jak dát svým kolegům vědět, že to, co zveřejnili, bylo zajímavé, užitečné, skvělé nebo zábavné. Podělte se o svou reakci! + reports: + reactions: + title: "Reakce" + description: "Seznam nejnovějších reakcí." + labels: + day: "Den" diff --git a/plugins/discourse-reactions/config/locales/server.da.yml b/plugins/discourse-reactions/config/locales/server.da.yml new file mode 100644 index 00000000000..8796a252201 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.da.yml @@ -0,0 +1,23 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +da: + site_settings: + discourse_reactions_enabled: "Aktiver discourse-reactions plugin." + discourse_reactions_enabled_reactions: "Definerer en liste over aktiverede reaktioner, enhver emoji er tilladt her." + discourse_reactions_desaturated_reaction_panel: "Reducerer visuel støj fra reaktioner ved at vise dem desaturerede, indtil du holder musen over dem." + badges: + first_reaction: + name: Første reaktion + description: Reagerede på indlægget + long_description: | + Dette emblem tildeles første gang du reagerer på et indlæg. Det at reagere på indlæg er en fantastisk måde at fortælle andre medlemmer, at det, de skrev om, var interessant, nyttigt, sejt eller sjovt. Del din reaktion! + reports: + reactions: + title: "Reaktioner" + description: "Liste over de seneste reaktioner." + labels: + day: "Dag" diff --git a/plugins/discourse-reactions/config/locales/server.de.yml b/plugins/discourse-reactions/config/locales/server.de.yml new file mode 100644 index 00000000000..b3ae709d9a5 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.de.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +de: + site_settings: + discourse_reactions_like_icon: "Definiert das Symbol, das für die Hauptreaktionsschaltfläche verwendet wird. Dies sollte ein Font Awesome-Symbolname sein, kein Emoji-Name." + discourse_reactions_reaction_for_like: "Legt die Reaktion fest, die mit der „Gefällt mir“-Aktion verbunden ist. Historische „Gefällt mir“-Datensätze werden nicht geändert, wenn diese Einstellung geändert wird. Es wird dringend empfohlen, diese Einstellung nie zu ändern." + discourse_reactions_enabled: "Aktiviere das discourse-reactions-Plug-in" + discourse_reactions_enabled_reactions: "Definiert eine Liste von aktivierten Reaktionen. Jedes Emoji ist hier erlaubt." + discourse_reactions_desaturated_reaction_panel: "Stellt Reaktionen weniger ablenkend in Graustufen dar, bis sie im Fokus des Zeigers stehen." + discourse_reactions_excluded_from_like: "Reaktionen, die nicht als „Gefällt mir“ gelten. Alle Reaktionen, die nicht auf dieser Liste stehen, werden in Bezug auf Abzeichen, Berichte und andere Zwecke als „Gefällt mir“ gewertet." + discourse_reactions_like_sync_enabled: "Wenn diese Option aktiviert ist, werden für vorherige Reaktionen die entsprechenden „Gefällt mir“-Einträge erstellt, außer für die Reaktionen, die in `discourse_reactions_excluded_from_like` definiert sind. Dieser Abgleich findet regelmäßig im Hintergrund statt und auch, wenn du `discourse_reactions_excluded_from_like` änderst." + errors: + invalid_excluded_emoji: "Du kannst keine Emojis ausschließen, die nicht in „discourse reactions enabled reactions“ enthalten sind, und du kannst das Emoji nicht ausschließen, das für „discourse reactions reaction for like“ verwendet wird." + badges: + first_reaction: + name: Erste Reaktion + description: Auf Beitrag reagiert + long_description: | + Das Abzeichen wird verliehen, wenn du das erste Mal auf einen Beitrag reagierst. Das Reagieren auf Beiträge ist eine tolle Art, um Mitglieder wissen zu lassen, dass ihr Beitrag interessant, nützlich, cool oder witzig war. Teile deine Reaktion! + reports: + reactions: + title: "Reaktionen" + description: "Die neusten Reaktionen anzeigen" + labels: + day: "Tag" diff --git a/plugins/discourse-reactions/config/locales/server.el.yml b/plugins/discourse-reactions/config/locales/server.el.yml new file mode 100644 index 00000000000..c591260164c --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.el.yml @@ -0,0 +1,21 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +el: + site_settings: + discourse_reactions_enabled: "Ενεργοποιήστε το πρόσθετο discourse-reactions." + badges: + first_reaction: + name: Πρώτη Αντίδραση + description: Αντέδρασε στην ανάρτηση + long_description: | + Το παράσημο χορηγείται την πρώτη φορά που έκανες αντίδραση σε μια ανάρτηση. Αντιδρώντας στις αναρτήσεις είναι ο καλύτερος τρόπος να δίνεις στα υπόλοιπα μέλη της κοινότητας να καταλάβουν ότι αυτό που ανάρτησαν ήταν ενδιαφέρον, χρήσιμο, έξυπνο ή διασκεδαστικό. Μοιράσου την αντίδραση σου! + reports: + reactions: + title: "Αντιδράσεις" + description: "Λίστα των πιο πρόσφατων αντιδράσεων." + labels: + day: "Ημέρα" diff --git a/plugins/discourse-reactions/config/locales/server.en.yml b/plugins/discourse-reactions/config/locales/server.en.yml new file mode 100644 index 00000000000..2909d9a9c24 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.en.yml @@ -0,0 +1,23 @@ +en: + site_settings: + discourse_reactions_like_icon: "Defines the icon used for the main reaction button. This should be a Font Awesome icon name, not an emoji name." + discourse_reactions_reaction_for_like: "Defines the reaction associated to the Like action. Historical Like records will not be changed if this setting changes, it is strongly recommended that this setting is never changed." + discourse_reactions_enabled: "Enable the discourse-reactions plugin." + discourse_reactions_enabled_reactions: "Defines a list of enabled reactions, any emoji is allowed here." + discourse_reactions_desaturated_reaction_panel: "Reduces visual noise of reactions by displaying them desaturated until hover." + discourse_reactions_excluded_from_like: "Reactions that do not count as a Like. Any reactions that are not on this list will count as a Like for badges, reporting, and other purposes." + discourse_reactions_like_sync_enabled: "If this is enabled, historical reactions will have their matching Like records created, except those reactions defined in `discourse_reactions_excluded_from_like`. This sync will happen on a regular basis in the background, and also when you change `discourse_reactions_excluded_from_like`." + errors: + invalid_excluded_emoji: "You cannot exclude emojis that are not in 'discourse reactions enabled reactions' and you cannot exclude the emoji used for 'discourse reactions reaction for like'." + badges: + first_reaction: + name: First Reaction + description: Reacted to the post + long_description: | + This badge is granted the first time you react to a post. Reacting to the posts is a great way to let your fellow community members know that what they posted was interesting, useful, cool, or fun. Share your reaction! + reports: + reactions: + title: "Reactions" + description: "List most recent reactions." + labels: + day: "Day" diff --git a/plugins/discourse-reactions/config/locales/server.en_GB.yml b/plugins/discourse-reactions/config/locales/server.en_GB.yml new file mode 100644 index 00000000000..2d4fa180ec7 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.en_GB.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +en_GB: diff --git a/plugins/discourse-reactions/config/locales/server.es.yml b/plugins/discourse-reactions/config/locales/server.es.yml new file mode 100644 index 00000000000..9fad3008bb5 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.es.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +es: + site_settings: + discourse_reactions_like_icon: "Define el icono utilizado para el botón principal de reacción. Debe ser un nombre de icono Font Awesome, no un nombre de emoji." + discourse_reactions_reaction_for_like: "Define la reacción asociada a la acción Me gusta. Los registros históricos de Me gusta no se modificarán si cambia este ajuste, por lo que se recomienda encarecidamente que este ajuste no se modifique nunca." + discourse_reactions_enabled: "Activar el plugin discourse-reactions." + discourse_reactions_enabled_reactions: "Define una lista de reacciones activadas. Se permite cualquier emoji." + discourse_reactions_desaturated_reaction_panel: "Reduce el ruido visual de las reacciones mostrándolas con menos color hasta que se pasa el ratón por encima." + discourse_reactions_excluded_from_like: "Reacciones que no cuentan como un Me gusta. Cualquier reacción que no esté en esta lista contará como un Me gusta para insignias, informes y otros fines." + discourse_reactions_like_sync_enabled: "Si está activada, se crearán los registros de Me gusta correspondientes a las reacciones históricas, excepto para las reacciones definidas en «discourse_reactions_excluded_from_like». Esta sincronización se realizará regularmente en segundo plano, y también cuando cambies «discourse_reactions_excluded_from_like»." + errors: + invalid_excluded_emoji: "No puedes excluir los emojis que no estén en «reacciones de Discourse que posibilitan reacciones» ni tampoco puedes excluir los emojis utilizados para «reacciones de Discourse para Me gusta»." + badges: + first_reaction: + name: Primera reacción + description: Reaccionó a una publicación + long_description: | + Esta medalla se concede la primera vez que reaccionas a una publicación. Las reacciones son una buena manera de hacer saber a las demás personas de la comunidad que lo que publicaron era interesante, útil, divertido o guay. ¡Comparte tus reacciones! + reports: + reactions: + title: "Reacciones" + description: "Listar las reacciones más recientes." + labels: + day: "Día" diff --git a/plugins/discourse-reactions/config/locales/server.et.yml b/plugins/discourse-reactions/config/locales/server.et.yml new file mode 100644 index 00000000000..9ceb592707d --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.et.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +et: + reports: + reactions: + labels: + day: "Päev" diff --git a/plugins/discourse-reactions/config/locales/server.fa_IR.yml b/plugins/discourse-reactions/config/locales/server.fa_IR.yml new file mode 100644 index 00000000000..a4262792796 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.fa_IR.yml @@ -0,0 +1,23 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fa_IR: + site_settings: + discourse_reactions_enabled: "فعال کردن افزونه discourse-reactions." + discourse_reactions_enabled_reactions: "فهرستی از واکنش‌های فعال را تعریف می‌کند، هر شکلک در اینجا مجاز است." + discourse_reactions_desaturated_reaction_panel: "سر و صدای بصری واکنش‌ها را با نمایش غیراشباع آنها تا زمان شناور کاهش می‌دهد." + badges: + first_reaction: + name: اولین واکنش‌ + description: به این نوشته واکنش نشان داد + long_description: | + این نشان زمانی اعطا می‌شود که برای اولین بار به یک نوشته واکنش نشان دادید. واکنش به نوشته‌ها روشی عالی برای این است که به اعضای انجمن اطلاع دهید که مطالبی که نوشته‌اند جالب، مفید، کارآمد یا سرگرم‌کننده است. واکنش خود را به اشتراک بگذارید! + reports: + reactions: + title: "واکنش‌‌ها" + description: "آخرین واکنش‌ها را فهرست کنید." + labels: + day: "روز" diff --git a/plugins/discourse-reactions/config/locales/server.fi.yml b/plugins/discourse-reactions/config/locales/server.fi.yml new file mode 100644 index 00000000000..50c72b31102 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.fi.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fi: + site_settings: + discourse_reactions_like_icon: "Määrittää pääreaktiopainikkeen kuvakkeen. Tämän pitäisi olla Font Awesome -kuvakkeen nimi, ei emojin nimi." + discourse_reactions_reaction_for_like: "Määrittää tykkäystoimintoon liittyvän reaktion. Historialliset tykkäystietueet eivät muutu, jos tämä asetus muuttuu. On vahvasti suositeltavaa , ettei tätä asetusta muuteta koskaan." + discourse_reactions_enabled: "Ota discourse-reactions-lisäosa käyttöön." + discourse_reactions_enabled_reactions: "Määrittää luettelon käytössä olevista reaktioista, kaikki emojit ovat sallittuja tässä." + discourse_reactions_desaturated_reaction_panel: "Vähentää reaktioiden visuaalista häiriötä näyttämällä ne vähemmän värikyllästettyinä, kunnes niitä osoitetaan." + discourse_reactions_excluded_from_like: "Reaktiot, joita ei lasketa tykkäyksiksi. Reaktiot, jotka eivät ole tässä luettelossa, lasketaan tykkäyksiksi kunniamerkeissä, raportoinnissa ja muissa tarkoituksissa." + discourse_reactions_like_sync_enabled: "Jos tämä on käytössä, historiallisiin reaktioihin luodaan vastaavat tykkäystietueet, paitsi niihin reaktioihin, jotka on määritelty \"discourse_reactions_excluded_from_like\"-asetuksessa. Tämä synkronointi tapahtuu säännöllisesti taustalla ja myös silloin, kun muutat \"discourse_reactions_excluded_from_like\"-asetusta." + errors: + invalid_excluded_emoji: "Et voi sulkea pois emojeita, jotka eivät ole \"discourse reactions enabled reactions\" -asetuksessa, etkä voi sulkea pois emojia, jota käytetään \"discourse reactions reaction for like\" -asetuksessa." + badges: + first_reaction: + name: Ensimmäinen reaktio + description: Reagoi viestiin + long_description: | + Tämä kunniamerkki myönnetään, kun ensi kertaa reagoit viestiin. Viesteihin reagoiminen on hyvä tapa viestiä yhteisön toiselle jäsenelle, että hänen viestinsä oli kiintoisa, hyödyllinen, osuva tai hauska. Jaa reaktiosi! + reports: + reactions: + title: "Reaktiot" + description: "Listaa viimeisimmät reaktiot." + labels: + day: "Päivä" diff --git a/plugins/discourse-reactions/config/locales/server.fr.yml b/plugins/discourse-reactions/config/locales/server.fr.yml new file mode 100644 index 00000000000..25d2b66b734 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.fr.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fr: + site_settings: + discourse_reactions_like_icon: "Définit l'icône utilisée pour le bouton de réaction principal. Il doit s'agir d'un nom d'icône Font Awesome et non d'un nom d'émoji." + discourse_reactions_reaction_for_like: "Définit la réaction associée à l'action J'aime. Les enregistrements historiques ne seront pas modifiés si ce paramètre change, il est fortement recommandé de ne jamais modifier ce paramètre." + discourse_reactions_enabled: "Activer l'extension discourse-reactions." + discourse_reactions_enabled_reactions: "Définit une liste de réactions activées, n'importe quel émoji est autorisé ici." + discourse_reactions_desaturated_reaction_panel: "Réduit le bruit visuel des réactions en les affichant désaturées lorsqu'elles ne sont pas survolées." + discourse_reactions_excluded_from_like: "Les réactions qui ne sont pas considérées comme des « J'aime ». Toute réaction ne figurant pas sur cette liste sera considérée comme un J'aime pour les badges, les rapports et à d'autres fins." + discourse_reactions_like_sync_enabled: "Si cette option est activée, les enregistrements J'aime correspondants seront créés pour les réactions historiques, à l'exception des réactions définies dans discourse_reactions_excluded_from_like. Cette synchronisation se produira régulièrement en arrière-plan, ainsi que lorsque vous modifierez « discourse_reactions_excluded_from_like »." + errors: + invalid_excluded_emoji: "Vous ne pouvez pas exclure les émojis qui ne figurent pas dans les « réactions discourse, réactions activées » et vous ne pouvez pas exclure les émojis utilisés pour « réactions discourse, réaction J'aime »." + badges: + first_reaction: + name: Première réaction + description: A réagi au message + long_description: | + Ce badge est accordé la première fois que vous réagissez à un message. Les réactions sont un excellent moyen de faire savoir aux autres membres de la communauté que leurs messages sont intéressants, utiles, cools ou amusants. Partagez vos réactions ! + reports: + reactions: + title: "Réactions" + description: "Répertorie les réactions les plus récentes." + labels: + day: "Jour" diff --git a/plugins/discourse-reactions/config/locales/server.gl.yml b/plugins/discourse-reactions/config/locales/server.gl.yml new file mode 100644 index 00000000000..55b92f91530 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.gl.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +gl: + reports: + reactions: + labels: + day: "Día" diff --git a/plugins/discourse-reactions/config/locales/server.he.yml b/plugins/discourse-reactions/config/locales/server.he.yml new file mode 100644 index 00000000000..b2b078ddc4c --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.he.yml @@ -0,0 +1,28 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +he: + site_settings: + discourse_reactions_like_icon: "הגדרת הסמל לשימוש לכפתור תגובת הרגש הראשי. אמור להיות שם סמל של Font Awesome, לא שם של אמוג׳י." + discourse_reactions_reaction_for_like: "מגדיר את התגובה שמשוייכת לפעולת לייק. לא יחול שינוי ברשומות לייק היסטוריות אם ההגדרה הזאת משתנה, מומלץ בחום אף פעם לא לשנות את ההגדרה הזאת." + discourse_reactions_enabled: "להפעיל את התוסף discourse-reactions (תחושות)." + discourse_reactions_enabled_reactions: "מגדיר רשימה של תחושות מופעלות, מותר להשתמש כאן בכל אמוג׳י שהוא." + discourse_reactions_desaturated_reaction_panel: "מפחית את העומס החזותי של סימוני התחושות על ידי הצגתם בצורה מעומעמת עד שמרחפים מעליהם." + discourse_reactions_excluded_from_like: "תגובות שלא נחשבות ללייק. תגובות שאינן מופיעות ברשימה הזאת תיחשבנה כלייק לעיטורים, דיווחים ומטרות נוספות." + errors: + invalid_excluded_emoji: "אי אפשר להחריג אמוג׳ים שאינם חלק מ‚תגובות רגש מופעלות ב־discourse’ ואי אפשר להחריג את האמוג׳י שמשמש ל‚תגובות רגש discourse ללייק’." + badges: + first_reaction: + name: תחושה ראשונה + description: הוסיפו תחושות לפוסט + long_description: | + עיטור זה מוענק בפעם הראשונה עם שליחת תחושה על פוסט. להגיב בתחושה לפוסט זו דרך מעולה כדי ליידע את חבריכם לקהילה שמה שהם פרסמו היה מעניין, שימושי, מגניב, או כייפי. שתפו את תחושותיכם! + reports: + reactions: + title: "תחושות" + description: "הצגת התחושות האחרונות." + labels: + day: "יום" diff --git a/plugins/discourse-reactions/config/locales/server.hr.yml b/plugins/discourse-reactions/config/locales/server.hr.yml new file mode 100644 index 00000000000..f33aed25647 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.hr.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hr: + reports: + reactions: + labels: + day: "Dan" diff --git a/plugins/discourse-reactions/config/locales/server.hu.yml b/plugins/discourse-reactions/config/locales/server.hu.yml new file mode 100644 index 00000000000..a36463c243c --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.hu.yml @@ -0,0 +1,28 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hu: + site_settings: + discourse_reactions_reaction_for_like: "Meghatározza a Kedvelés művelethez társított reakciót. Ha ez a beállítás megváltozik, a régebbi reakciók nem módosulnak, ezért erősen ajánlott , hogy ezt a beállítást soha ne módosítsa." + discourse_reactions_enabled: "A discourse-reaction bővítmény engedélyezése." + discourse_reactions_enabled_reactions: "Meghatározza az engedélyezett reakciók listáját, itt minden emodzsi engedélyezett." + discourse_reactions_desaturated_reaction_panel: "Csökkenti a reakciók vizuális zaját azáltal, hogy ki vannak szürkítve, ameddig föléjük nem viszi az egeret." + discourse_reactions_excluded_from_like: "Olyan reakciók, amelyek nem számítanak Kedvelésnek. Azok a reakciók, amelyek nem szerepelnek ezen a listán, Kedvelésenk számítanak a jelvények, a jelentéstétel és egyéb célok érdekében." + discourse_reactions_like_sync_enabled: "Ha ez be van kapcsolva, az előzményreakciókhoz a megfelelő Like-rekordok jönnek létre, kivéve a „discourse_reactions_excluded_from_like” paraméterben meghatározott reakciókat. Ez a szinkronizálás rendszeresen megtörténik a háttérben, és akkor is, ha módosítja a `discourse_reactions_excluded_from_like` paramétert." + errors: + invalid_excluded_emoji: "Nem zárhatja ki azokat a hangulatjeleket, amelyek nem szerepelnek a „beszédreakciók engedélyezett reakcióiban”, és nem zárhatók ki a „beszédreakciók hasonlóra adott reakciói” emojik sem." + badges: + first_reaction: + name: Első reakció + description: Reagált a bejegyzésre + long_description: | + Ezt a jelvényt akkor kapja meg, amikor először reagál egy bejegyzésre. A bejegyzésekre reagálás nagyszerű módja annak, hogy a közösség tagjai tudják, hogy amit közzé tettek, érdekesek, hasznosak, jók vagy szórakoztatók voltak az Ön számára. Ossza meg a reakcióját! + reports: + reactions: + title: "Reakciók" + description: "A legutóbbi reakciók felsorolása." + labels: + day: "Nap" diff --git a/plugins/discourse-reactions/config/locales/server.hy.yml b/plugins/discourse-reactions/config/locales/server.hy.yml new file mode 100644 index 00000000000..deb702989d5 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.hy.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hy: + reports: + reactions: + labels: + day: "Օր" diff --git a/plugins/discourse-reactions/config/locales/server.id.yml b/plugins/discourse-reactions/config/locales/server.id.yml new file mode 100644 index 00000000000..0bbc70d2f15 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.id.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +id: + reports: + reactions: + labels: + day: "Hari" diff --git a/plugins/discourse-reactions/config/locales/server.it.yml b/plugins/discourse-reactions/config/locales/server.it.yml new file mode 100644 index 00000000000..73a9dc3cdd7 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.it.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +it: + site_settings: + discourse_reactions_like_icon: "Definisce l'icona utilizzata per il pulsante di reazione principale. Dovrebbe essere il nome di un'icona Font Awesome, non il nome di un emoji." + discourse_reactions_reaction_for_like: "Definisce la reazione associata all'azione Mi piace. La cronologia dei Mi piace non verrà modificata se questa impostazione cambia, è fortemente consigliato che questa impostazione non venga mai modificata." + discourse_reactions_enabled: "Abilita il plugin discourse-reactions." + discourse_reactions_enabled_reactions: "Definisce un elenco di reazioni abilitate, tutti gli emoji sono consentiti qui." + discourse_reactions_desaturated_reaction_panel: "Riduce il disturbo visivo delle reazioni mostrandole desaturate fino al passaggio del mouse." + discourse_reactions_excluded_from_like: "Reazioni che non contano come Mi piace. Qualsiasi reazione non presente in questo elenco conterà come un Mi piace per distintivi, report e altri scopi." + discourse_reactions_like_sync_enabled: "Se questa opzione è abilitata, per le reazioni passate verranno creati i corrispondenti record di Mi piace, a eccezione delle reazioni definite in \"discourse_reactions_excluded_from_like\". Questa sincronizzazione avverrà regolarmente in background e anche quando modifichi il valore di \"discourse_reactions_excluded_from_like\"." + errors: + invalid_excluded_emoji: "Non è possibile escludere emoji che non si trovano in \"discourse reactions enabled reactions\" e non è possibile escludere le emoji utilizzate per \"discourse reactions reaction for like\"." + badges: + first_reaction: + name: Prima reazione + description: Ha reagito al messaggio + long_description: | + Questo distintivo è assegnato la prima volta che reagisci a un messaggio. Reagire ai messaggi è un ottimo modo per far sapere agli altri membri della tua comunità che quello che hanno pubblicato era interessante, utile, o divertente. Condividi la tua reazione! + reports: + reactions: + title: "Reazioni" + description: "Elenca le reazioni più recenti." + labels: + day: "Giorno" diff --git a/plugins/discourse-reactions/config/locales/server.ja.yml b/plugins/discourse-reactions/config/locales/server.ja.yml new file mode 100644 index 00000000000..2531bb1ea04 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.ja.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ja: + site_settings: + discourse_reactions_like_icon: "メインのリアクションボタンに使用するアイコンを定義します。これは絵文字名ではなく、Font Awesome アイコン名です。" + discourse_reactions_reaction_for_like: "「いいね!」操作に関連付けられたリアクションを定義します。この設定を変更しても、過去の「いいね!」の記録は変更されません。この設定を絶対に変更しないことを強くお勧めします。" + discourse_reactions_enabled: "discourse-reactions プラグインを有効にします。" + discourse_reactions_enabled_reactions: "有効なリアクションのリストを定義します。ここではすべての絵文字を使用できます。" + discourse_reactions_desaturated_reaction_panel: "ホバーされるまで彩度を下げて表示することで、リアクションの視覚的な煩わしさを低減します。" + discourse_reactions_excluded_from_like: "「いいね!」としてカウントされないリアクション。このリストに含まれないリアクションは、バッジ、レポート作成、およびその他の目的で「いいね!」としてカウントされます。" + discourse_reactions_like_sync_enabled: "これが有効である場合、`discourse_reactions_excluded_from_like` で定義されたリアクションを除き、過去のリアクションにはそれに一致する「いいね!」レコードが作成されます。この同期はバックグラウンドで定期的に処理されます。また、`discourse_reactions_excluded_from_like` を変更した場合にも処理されます。" + errors: + invalid_excluded_emoji: "「Discourse リアクション対応リアクション」含まれない絵文字を除外できません。また、「「いいね!」に対する Discourse リアクション」に使用される絵文字は除外できません。" + badges: + first_reaction: + name: 最初のリアクション + description: 投稿に反応しました + long_description: | + このバッジは、初めて投稿に反応したときに付与されます。投稿へのリアクションは、コミュニティメンバーが投稿した内容が面白い、役に立つ、かっこいい、楽しいなどの感想を仲間に伝えるのに最適な方法です。あなたのリアクションを共有しましょう! + reports: + reactions: + title: "リアクション" + description: "直近のリアクションをリストします。" + labels: + day: "日" diff --git a/plugins/discourse-reactions/config/locales/server.ko.yml b/plugins/discourse-reactions/config/locales/server.ko.yml new file mode 100644 index 00000000000..34a28db0fce --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.ko.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ko: + reports: + reactions: + labels: + day: "일" diff --git a/plugins/discourse-reactions/config/locales/server.lt.yml b/plugins/discourse-reactions/config/locales/server.lt.yml new file mode 100644 index 00000000000..f36cb83d3e1 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.lt.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lt: + reports: + reactions: + labels: + day: "Diena" diff --git a/plugins/discourse-reactions/config/locales/server.lv.yml b/plugins/discourse-reactions/config/locales/server.lv.yml new file mode 100644 index 00000000000..09d3789d572 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.lv.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lv: + reports: + reactions: + labels: + day: "Diena" diff --git a/plugins/discourse-reactions/config/locales/server.nb_NO.yml b/plugins/discourse-reactions/config/locales/server.nb_NO.yml new file mode 100644 index 00000000000..c9293484b67 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.nb_NO.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nb_NO: + reports: + reactions: + labels: + day: "Dag" diff --git a/plugins/discourse-reactions/config/locales/server.nl.yml b/plugins/discourse-reactions/config/locales/server.nl.yml new file mode 100644 index 00000000000..7a560a3db66 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.nl.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nl: + site_settings: + discourse_reactions_like_icon: "Definieert het pictogram dat voor de hoofdreactieknop wordt gebruikt. Dit moet een Font Awesome-pictogramnaam zijn, geen emojinaam." + discourse_reactions_reaction_for_like: "Definieert de reactie die is gekoppeld aan de Like-actie. Historische gegevens worden niet gewijzigd als deze instelling wordt gewijzigd. Het wordt sterk aanbevolen deze instelling nooit te wijzigen." + discourse_reactions_enabled: "Schakel de Discourse-reactiesplug-in in." + discourse_reactions_enabled_reactions: "Definieert een lijst van ingeschakelde reacties, elke emoji is hier toegestaan." + discourse_reactions_desaturated_reaction_panel: "Vermindert visuele ruis van reacties door ze onverzadigd weer te geven totdat er met de muis overheen wordt gegaan." + discourse_reactions_excluded_from_like: "Reacties die niet tellen als een Like. Alle reacties die niet op deze lijst staan, tellen als een Like voor badges, rapportage en andere doeleinden." + discourse_reactions_like_sync_enabled: "Als dit is ingeschakeld, worden bij historische reacties de overeenkomende Like-gegevens gecreëerd, behalve reacties die zijn gedefinieerd in 'discourse_reactions_excluded_from_like'. Deze synchronisatie zal regelmatig op de achtergrond plaatsvinden, ook wanneer je 'discourse_reactions_excluded_from_like' wijzigt." + errors: + invalid_excluded_emoji: "Je kunt geen emoji's uitsluiten die niet voorkomen in 'discoursereacties ingeschakelde reacties' en je kunt de emoji's die worden gebruikt voor 'discoursereacties reactie voor like' niet uitsluiten." + badges: + first_reaction: + name: Eerste reactie + description: Gereageerd op het bericht + long_description: | + Deze badge wordt toegekend als je voor het eerst reageert op een bericht. Reageren op berichten is een geweldige manier om andere communityleden te laten weten dat wat ze hebben geplaatst interessant, nuttig, cool of leuk was. Deel je reactie! + reports: + reactions: + title: "Reacties" + description: "Lijst van meest recente reacties." + labels: + day: "Day" diff --git a/plugins/discourse-reactions/config/locales/server.pl_PL.yml b/plugins/discourse-reactions/config/locales/server.pl_PL.yml new file mode 100644 index 00000000000..b7de08dbddf --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.pl_PL.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pl_PL: + site_settings: + discourse_reactions_like_icon: "Określa ikonę używaną dla głównego przycisku reakcji. Powinna to być nazwa ikony Font Awesome, a nie nazwa emoji." + discourse_reactions_reaction_for_like: "Określa reakcję związaną z akcją \"lubię to\". Historyczne rekordy \"lubię to\" nie zostaną zmienione, jeśli to ustawienie ulegnie zmianie, dlatego zdecydowanie zaleca się, aby nigdy nie zmieniać tego ustawienia." + discourse_reactions_enabled: "Włącz wtyczkę discourse-reactions." + discourse_reactions_enabled_reactions: "Definiuje listę włączonych reakcji, wszystkie emoji są tutaj dozwolone." + discourse_reactions_desaturated_reaction_panel: "Redukuje wizualny szum reakcji poprzez wyświetlanie ich w postaci nienasyconej aż do momentu najechania na nie." + discourse_reactions_excluded_from_like: "Reakcje, które nie liczą się jako \"lubię to\". Wszelkie reakcje, które nie znajdują się na tej liście, będą liczone jako polubienia do odznak, raportów i innych celów." + discourse_reactions_like_sync_enabled: "Jeśli ta opcja jest włączona, dla historycznych reakcji zostaną utworzone odpowiadające im rekordy polubień, z wyjątkiem tych reakcji, które są zdefiniowane w `discourse_reactions_excluded_from_like`. Synchronizacja będzie odbywać się regularnie w tle, a także w momencie zmiany ustawienia `discourse_reactions_excluded_from_like`." + errors: + invalid_excluded_emoji: "Nie możesz wykluczyć emotikonów, które nie znajdują się w ustawieniu 'discourse reactions enabled reactions', ani emotikony używanej w ustawieniu 'discourse reactions reaction for like'." + badges: + first_reaction: + name: Pierwsza reakcja + description: Reakcja na post + long_description: | + Ta odznaka jest przyznawana po raz pierwszy przy reagowaniu na wpis. Reagowanie na posty to świetny sposób na to, by członkowie społeczności wiedzieli, że to, co opublikowali, było interesujące, przydatne, fajne lub zabawne. Podziel się swoją reakcją! + reports: + reactions: + title: "Reakcje" + description: "Lista najnowszych reakcji." + labels: + day: "Dzień" diff --git a/plugins/discourse-reactions/config/locales/server.pt.yml b/plugins/discourse-reactions/config/locales/server.pt.yml new file mode 100644 index 00000000000..6cb1bdbf37e --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.pt.yml @@ -0,0 +1,21 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt: + site_settings: + discourse_reactions_enabled: "Ativar o plugin de reações discourse." + discourse_reactions_enabled_reactions: "Define uma lista de reações ativadas, qualquer emoji é permitido aqui." + badges: + first_reaction: + name: Primeira Reação + description: Reagiu à publicação + long_description: | + Este distintivo é concedido da primeira vez que reage a uma publicação. Reagir a publicações é uma excelente maneira de fazer os restantes membros da comunidade saber que o que eles publicaram foi interessante, útil, giro, ou divertido. Partilhe a sua reação! + reports: + reactions: + title: "Reações" + labels: + day: "Dia" diff --git a/plugins/discourse-reactions/config/locales/server.pt_BR.yml b/plugins/discourse-reactions/config/locales/server.pt_BR.yml new file mode 100644 index 00000000000..f8c52f4185c --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.pt_BR.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt_BR: + site_settings: + discourse_reactions_like_icon: "Define o ícone usado para o botão de reação principal. Deve ser um nome de ícone do Font Awesome, não um nome de emoji." + discourse_reactions_reaction_for_like: "Define a reação associada à ação Curtir. Os registros do tipo Histórico não mudarão se esta configuração for alterada. É altamente recomendável nunca alterar esta configuração." + discourse_reactions_enabled: "Ative o plugin de reações do Discourse." + discourse_reactions_enabled_reactions: "Define uma lista de reações ativadas, qualquer emoji é permitido aqui." + discourse_reactions_desaturated_reaction_panel: "Reduz a poluição visual das reações exibindo-as sem saturação até pairar." + discourse_reactions_excluded_from_like: "Reações que não são consideradas Curtidas. As reações que não estiverem nesta lista serão consideradas Curtidas para emblemas, denúncias e outras finalidades." + discourse_reactions_like_sync_enabled: "Ao ativar esta funcionalidade, serão criados registros de Curtidas para as reações de histórico, exceto as que forem definidas em \"discourse_reactions_excluded_from_like\". A sincronização acontecerá regularmente em segundo plano e também ao alterar \"discourse_reactions_excluded_from_like\"." + errors: + invalid_excluded_emoji: "Não é possível excluir emojis que não estiverem em \"reações ativadas por reações do Discourse\" nem excluir o emoji usado para \"reação a reações do Discourse para curtidas\"." + badges: + first_reaction: + name: Primeira Reação + description: Reagiu à postagem + long_description: | + Este emblema é concedido na primeira vez que você reage a uma postagem. Reagir às postagens é uma ótima maneira de informar aos outros membros da comunidade que o conteúdo da sua postagem foi interessante, útil, legal ou divertido. Compartilhe sua reação! + reports: + reactions: + title: "Reações" + description: "Liste as reações mais recentes." + labels: + day: "Dia" diff --git a/plugins/discourse-reactions/config/locales/server.ro.yml b/plugins/discourse-reactions/config/locales/server.ro.yml new file mode 100644 index 00000000000..2a7a32bc987 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.ro.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ro: + site_settings: + discourse_reactions_like_icon: "Definește iconul folosit pentru butonul principal de reacție. Acesta ar trebui să fie un nume de icon Font Awesome , nu un nume de emoji." + discourse_reactions_reaction_for_like: "Definește reacția asociată acțiunii de apreciere. Înregistrările istorice de apreciere nu vor fi modificate dacă se schimbă această setare; se recomandă cu tărie ca această setare să nu fie niciodată modificată." + discourse_reactions_enabled: "Activează modulul discourse-reactions." + discourse_reactions_enabled_reactions: "Definește o listă de reacții activate, orice emoticon este permis aici." + discourse_reactions_desaturated_reaction_panel: "Reduce zgomotul vizual al reacțiilor afișându-le nesaturate până la trecerea cu mausul peste." + discourse_reactions_excluded_from_like: "Reacții care nu se iau în considerare ca o apreciere. Toate reacțiile care nu se regăsesc pe această listă se vor contoriza ca o apreciere pentru insigne, raportare și alte scopuri." + discourse_reactions_like_sync_enabled: "Dacă această opțiune este activată, reacțiile istorice vor avea înregistrările lor de aprecieri create, cu excepția acelor reacții definite în `discourse_reactions_excluded_from_like`. Această sincronizare va avea loc în mod regulat în fundal și, de asemenea, atunci când modifici `discourse_reactions_excluded_from_like`." + errors: + invalid_excluded_emoji: "Nu poți exclude emoticon-urile care nu se regăsesc în „reacțiile activate din discourse reactions” și nu poți exclude emoticon-urile utilizate pentru „reacția de apreciere din discourse reactions”." + badges: + first_reaction: + name: Prima reacție + description: A reacționat la articol + long_description: | + Această insignă este acordată prima dată când reacționezi la un articol. Reacționarea la articole este o modalitate excelentă de a-i face pe colegii tăi să știe că ceea ce au publicat a fost interesant, util, cool sau distractiv. Împărtășește-ți reacția! + reports: + reactions: + title: "Reacții" + description: "Enumeră cele mai recente reacții." + labels: + day: "Zi" diff --git a/plugins/discourse-reactions/config/locales/server.ru.yml b/plugins/discourse-reactions/config/locales/server.ru.yml new file mode 100644 index 00000000000..7c8a62af6c5 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.ru.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ru: + site_settings: + discourse_reactions_like_icon: "Определяет значок, используемый для основной кнопки реакции. Это должно быть название значка Font Awesome, а не название эмодзи." + discourse_reactions_reaction_for_like: "Определяет реакцию, связанную с действием «Нравится». Исторические записи о лайках не будут изменены, если этот параметр изменится, поэтому настоятельно рекомендуется никогда его не менять." + discourse_reactions_enabled: "Включить плагин 'discourse-reactions'." + discourse_reactions_enabled_reactions: "Определение списка доступных реакций. Разрешены любые эмодзи." + discourse_reactions_desaturated_reaction_panel: "Уменьшение визуального шума. Значки реакций отображаются обесцвеченными до наведения на них курсора." + discourse_reactions_excluded_from_like: "Реакции, которые не засчитываются как лайк. Любые реакции, которых нет в этом списке, будут засчитываться как лайки для наград, отчетов и других целей." + discourse_reactions_like_sync_enabled: "Если эта опция включена, для исторических реакций будут созданы соответствующие записи «Нравится», за исключением тех реакций, которые определены в `discourse_reactions_excluded_from_like`. Эта синхронизация будет происходить регулярно в фоновом режиме, а также при изменении `discourse_reactions_excluded_from_like`." + errors: + invalid_excluded_emoji: "Нельзя исключить эмодзи, которые не входят в `discourse_reactions_enabled_reactions`, и нельзя исключить эмодзи из `discourse_reactions_reaction_for_like`." + badges: + first_reaction: + name: Первая реакция + description: Пользователь первый раз отреагировал на сообщение + long_description: | + Эта награда выдается, когда вы первый раз отреагировали на сообщение. Подобные действия - это отличный способ сообщить участникам сообщества, что они опубликовали статью, не оставившую вас равнодушным. Не стесняйтесь делиться эмоциями! + reports: + reactions: + title: "Реакции" + description: "Перечень самых последних реакций." + labels: + day: "День" diff --git a/plugins/discourse-reactions/config/locales/server.sk.yml b/plugins/discourse-reactions/config/locales/server.sk.yml new file mode 100644 index 00000000000..6c764f1943b --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.sk.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sk: + reports: + reactions: + labels: + day: "Deň" diff --git a/plugins/discourse-reactions/config/locales/server.sl.yml b/plugins/discourse-reactions/config/locales/server.sl.yml new file mode 100644 index 00000000000..23489a48b1f --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.sl.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sl: diff --git a/plugins/discourse-reactions/config/locales/server.sq.yml b/plugins/discourse-reactions/config/locales/server.sq.yml new file mode 100644 index 00000000000..2f97aebc323 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.sq.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sq: + reports: + reactions: + labels: + day: "Dit" diff --git a/plugins/discourse-reactions/config/locales/server.sr.yml b/plugins/discourse-reactions/config/locales/server.sr.yml new file mode 100644 index 00000000000..88d63d6ae1a --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.sr.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sr: diff --git a/plugins/discourse-reactions/config/locales/server.sv.yml b/plugins/discourse-reactions/config/locales/server.sv.yml new file mode 100644 index 00000000000..c9097b8ddd5 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.sv.yml @@ -0,0 +1,23 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sv: + site_settings: + discourse_reactions_enabled: "Aktivera tillägget discourse-reactions." + discourse_reactions_enabled_reactions: "Definierar en lista över aktiverade reaktioner. Alla emojier tillåts här." + discourse_reactions_desaturated_reaction_panel: "Minskar reaktioners visuella inverkan genom att visa dem omättade tills du håller pekaren över dem." + badges: + first_reaction: + name: Första reaktionen + description: Reagerade på inlägget + long_description: | + Denna utmärkelse beviljas första gången du reagerar på ett inlägg. Att reagera på inlägg är ett bra sätt att låta dina forummedlemmar veta att det de har publicerat var intressant, användbart, coolt eller roligt. Dela med dig av din reaktion! + reports: + reactions: + title: "Reaktioner" + description: "Visa lista med de senaste reaktionerna." + labels: + day: "Dag" diff --git a/plugins/discourse-reactions/config/locales/server.sw.yml b/plugins/discourse-reactions/config/locales/server.sw.yml new file mode 100644 index 00000000000..a3ac4e14bc7 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.sw.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sw: + reports: + reactions: + labels: + day: "Siku" diff --git a/plugins/discourse-reactions/config/locales/server.te.yml b/plugins/discourse-reactions/config/locales/server.te.yml new file mode 100644 index 00000000000..6e03611de14 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.te.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +te: + reports: + reactions: + labels: + day: "రోజు" diff --git a/plugins/discourse-reactions/config/locales/server.th.yml b/plugins/discourse-reactions/config/locales/server.th.yml new file mode 100644 index 00000000000..7de85ff91c4 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.th.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +th: diff --git a/plugins/discourse-reactions/config/locales/server.tr_TR.yml b/plugins/discourse-reactions/config/locales/server.tr_TR.yml new file mode 100644 index 00000000000..32fc087f3e6 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.tr_TR.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +tr_TR: + site_settings: + discourse_reactions_like_icon: "Ana tepki düğmesi için kullanılan simgeyi tanımlar. Bu bir emoji adı değil, Font Awesome simge adı olmalıdır." + discourse_reactions_reaction_for_like: "Beğen eylemiyle ilişkili tepkiyi tanımlar. Bu ayar değişirse Geçmiş Beğeni kayıtları değişmez; bu ayarın hiçbir zaman değiştirilmemesi şiddetle tavsiye edilir ." + discourse_reactions_enabled: "Discourse-tepkileri eklentisini etkinleştirin." + discourse_reactions_enabled_reactions: "Etkinleştirilmiş tepkilerin bir listesini tanımlar, burada tüm emoji'lere izin verilir." + discourse_reactions_desaturated_reaction_panel: "Tepkileri üzerine gelene kadar doygunluğu azaltılmış şekilde görüntüleyerek görsel gürültüyü azaltır." + discourse_reactions_excluded_from_like: "Beğeni olarak sayılmayan tepkiler. Bu listede yer almayan herhangi bir tepki, rozetler, raporlama ve diğer amaçlar için Beğeni olarak sayılır." + discourse_reactions_like_sync_enabled: "Bu etkinleştirilirse `se_reactions_excluded_from_like` içinde tanımlanan tepkiler dışında, geçmiş tepkilerin eşleşen Beğeni kayıtları oluşturulur. Bu senkronizasyon arka planda düzenli olarak gerçekleşir ve ayrıca `se_reactions_excluded_from_like` seçeneğini değiştirdiğinizde gerçekleşir." + errors: + invalid_excluded_emoji: "\"Discourse tepkileri etkinleştirilmiş tepkiler\" içinde olmayan emoji'leri hariç tutamazsınız ve \"beğeni için discourse tepkileri\" için kullanılan emoji'leri hariç tutamazsınız." + badges: + first_reaction: + name: İlk Tepki + description: Gönderiye tepki verdi + long_description: | + Bu rozet, bir gönderiye ilk kez tepki verdiğinizde verilir. Gönderilere tepki vermek, topluluk üyesi arkadaşlarınıza gönderdikleri şeyin ilginç, yararlı, havalı veya eğlenceli olduğunu bildirmenin harika bir yoludur. Tepkinizi paylaşın! + reports: + reactions: + title: "Tepkiler" + description: "En son tepkileri listeleyin." + labels: + day: "Gün" diff --git a/plugins/discourse-reactions/config/locales/server.ug.yml b/plugins/discourse-reactions/config/locales/server.ug.yml new file mode 100644 index 00000000000..add5acb1c63 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.ug.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ug: + reports: + reactions: + labels: + day: "كۈن" diff --git a/plugins/discourse-reactions/config/locales/server.uk.yml b/plugins/discourse-reactions/config/locales/server.uk.yml new file mode 100644 index 00000000000..8378743939b --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.uk.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +uk: + reports: + reactions: + labels: + day: "День" diff --git a/plugins/discourse-reactions/config/locales/server.ur.yml b/plugins/discourse-reactions/config/locales/server.ur.yml new file mode 100644 index 00000000000..7c899972672 --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.ur.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ur: + reports: + reactions: + labels: + day: "دن" diff --git a/plugins/discourse-reactions/config/locales/server.vi.yml b/plugins/discourse-reactions/config/locales/server.vi.yml new file mode 100644 index 00000000000..cbfcb0f5b9b --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.vi.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +vi: + reports: + reactions: + labels: + day: "Ngày" diff --git a/plugins/discourse-reactions/config/locales/server.zh_CN.yml b/plugins/discourse-reactions/config/locales/server.zh_CN.yml new file mode 100644 index 00000000000..f84da24f36f --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.zh_CN.yml @@ -0,0 +1,29 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_CN: + site_settings: + discourse_reactions_like_icon: "定义用于主回应按钮的图标。这应该是一个 Font Awesome 图标名称,而不是表情符号名称。" + discourse_reactions_reaction_for_like: "定义与“赞”操作相关的回应。如果此设置发生变化,历史“赞”记录将不会更改,强烈建议永远不要更改此设置。" + discourse_reactions_enabled: "启用 discourse-reactions 插件。" + discourse_reactions_enabled_reactions: "定义启用的回应列表,此处允许使用任何表情符号。" + discourse_reactions_desaturated_reaction_panel: "通过在悬停前显示不饱和的回应,减少回应的视觉噪音。" + discourse_reactions_excluded_from_like: "不计为“赞”的回应。任何不在此列表中的回应都将计为“赞”,用于徽章、报告和其他目的。" + discourse_reactions_like_sync_enabled: "如果启用此功能,历史回应将创建其匹配的“赞”记录,`discourse_reactions_excluded_from_like` 中定义的回应除外。此同步将在后台定期发生,当您更改 `discourse_reactions_excluded_from_like` 时也会进行同步。" + errors: + invalid_excluded_emoji: "您不能排除不在 'discourse reactions enabled reactions' 中的表情符号,也不能排除用于 'discourse reactions reaction for like' 的表情符号。" + badges: + first_reaction: + name: 首次回应 + description: 回应了帖子 + long_description: | + 您第一次回应帖子时会被授予此徽章。回应帖子是让您的社区成员知道他们发布的内容有趣、有用、酷或好玩的绝佳方式。分享您的回应! + reports: + reactions: + title: "回应" + description: "列出最近的回应。" + labels: + day: "天" diff --git a/plugins/discourse-reactions/config/locales/server.zh_TW.yml b/plugins/discourse-reactions/config/locales/server.zh_TW.yml new file mode 100644 index 00000000000..c2c41727eac --- /dev/null +++ b/plugins/discourse-reactions/config/locales/server.zh_TW.yml @@ -0,0 +1,23 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_TW: + site_settings: + discourse_reactions_enabled: "啟用discourse-reactions插件" + discourse_reactions_enabled_reactions: "定義一組啟用反應的清單,並允許可用的表情符號於本清單中。" + discourse_reactions_desaturated_reaction_panel: "通過將反應顯示為去飽和度,直到懸停,以減少反應的視覺噪聲。" + badges: + first_reaction: + name: 頭一個反應 + description: 對貼文的反應 + long_description: | + 這個徽章是在您第一次對一個貼文做出反應時授予的。對貼文作出反應是一個很好的方式以讓您的社群成員知道他們發布的貼文是有趣、有用、酷或是好玩的。分享您的反應吧! + reports: + reactions: + title: "反應" + description: "列出最近的反應。" + labels: + day: "天" diff --git a/plugins/discourse-reactions/config/settings.yml b/plugins/discourse-reactions/config/settings.yml new file mode 100644 index 00000000000..b13319081fe --- /dev/null +++ b/plugins/discourse-reactions/config/settings.yml @@ -0,0 +1,25 @@ +discourse_reactions: + discourse_reactions_enabled: + default: false + client: true + discourse_reactions_like_icon: + client: true + default: "heart" + discourse_reactions_reaction_for_like: + client: true + type: enum + default: "heart" + enum: ReactionForLikeSiteSettingEnum + discourse_reactions_enabled_reactions: + type: emoji_list + default: "+1|laughing|open_mouth|clap|confetti_ball|hugs" + client: true + discourse_reactions_desaturated_reaction_panel: + default: false + client: true + discourse_reactions_excluded_from_like: + type: emoji_list + default: "-1" + validator: "ReactionsExcludedFromLikeSiteSettingValidator" + discourse_reactions_like_sync_enabled: + default: true diff --git a/plugins/discourse-reactions/db/fixtures/001_badges.rb b/plugins/discourse-reactions/db/fixtures/001_badges.rb new file mode 100644 index 00000000000..094d84e2d71 --- /dev/null +++ b/plugins/discourse-reactions/db/fixtures/001_badges.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +first_reaction_query = <<~SQL + SELECT user_id, created_at AS granted_at, post_id + FROM ( + SELECT ru.post_id, ru.user_id, ru.created_at, + ROW_NUMBER() OVER (PARTITION BY ru.user_id ORDER BY ru.created_at) AS row_number + FROM discourse_reactions_reaction_users ru + JOIN badge_posts p ON ru.post_id = p.id + WHERE :backfill + OR ru.post_id IN (:post_ids) + ) x + WHERE row_number = 1 +SQL + +Badge.seed(:name) do |b| + b.name = "First Reaction" + b.default_icon = "face-smile" + b.badge_type_id = BadgeType::Bronze + b.multiple_grant = false + b.target_posts = true + b.show_posts = true + b.query = first_reaction_query + b.default_badge_grouping_id = BadgeGrouping::GettingStarted + b.trigger = Badge::Trigger::PostRevision + b.system = true +end diff --git a/plugins/discourse-reactions/db/migrate/20201217062301_create_discourse_reactions_reactions_table.rb b/plugins/discourse-reactions/db/migrate/20201217062301_create_discourse_reactions_reactions_table.rb new file mode 100644 index 00000000000..5fcce776d43 --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20201217062301_create_discourse_reactions_reactions_table.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateDiscourseReactionsReactionsTable < ActiveRecord::Migration[6.0] + def change + create_table :discourse_reactions_reactions do |t| + t.integer :post_id + t.integer :reaction_type + t.string :reaction_value + t.integer :reaction_users_count + t.timestamps + end + add_index :discourse_reactions_reactions, :post_id + add_index :discourse_reactions_reactions, + %i[post_id reaction_type reaction_value], + unique: true, + name: "reaction_type_reaction_value" + end +end diff --git a/plugins/discourse-reactions/db/migrate/20201217062324_create_discourse_reactions_reaction_users_table.rb b/plugins/discourse-reactions/db/migrate/20201217062324_create_discourse_reactions_reaction_users_table.rb new file mode 100644 index 00000000000..1c674742e22 --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20201217062324_create_discourse_reactions_reaction_users_table.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateDiscourseReactionsReactionUsersTable < ActiveRecord::Migration[6.0] + def change + create_table :discourse_reactions_reaction_users do |t| + t.integer :reaction_id + t.integer :user_id + t.timestamps + end + add_index :discourse_reactions_reaction_users, :reaction_id + add_index :discourse_reactions_reaction_users, + %i[reaction_id user_id], + unique: true, + name: "reaction_id_user_id" + end +end diff --git a/plugins/discourse-reactions/db/migrate/20201217062343_add_post_id_to_discourse_reactions_reactions_users.rb b/plugins/discourse-reactions/db/migrate/20201217062343_add_post_id_to_discourse_reactions_reactions_users.rb new file mode 100644 index 00000000000..5ee85a35af1 --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20201217062343_add_post_id_to_discourse_reactions_reactions_users.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddPostIdToDiscourseReactionsReactionsUsers < ActiveRecord::Migration[6.0] + def change + add_column :discourse_reactions_reaction_users, :post_id, :integer + + add_index :discourse_reactions_reaction_users, + %i[user_id post_id], + unique: true, + name: "user_id_post_id" + end +end diff --git a/plugins/discourse-reactions/db/migrate/20211022154420_enable_reactions_if_already_installed.rb b/plugins/discourse-reactions/db/migrate/20211022154420_enable_reactions_if_already_installed.rb new file mode 100644 index 00000000000..5e7cd24409b --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20211022154420_enable_reactions_if_already_installed.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class EnableReactionsIfAlreadyInstalled < ActiveRecord::Migration[6.1] + def up + reactions_installed_at = DB.query_single(<<~SQL)&.first + SELECT created_at FROM schema_migration_details WHERE version='20201217062301' + SQL + + if reactions_installed_at && reactions_installed_at < Date.new(2021, 10, 21) + # The plugin was installed before we changed it to be disabled-by-default + # Therefore, if there is no existing database value, enable the plugin + execute <<~SQL + INSERT INTO site_settings(name, data_type, value, created_at, updated_at) + VALUES('discourse_reactions_enabled', 5, 't', NOW(), NOW()) + ON CONFLICT (name) DO NOTHING + SQL + end + end + + def down + # do nothing + end +end diff --git a/plugins/discourse-reactions/db/migrate/20220112091339_reset_erroneous_like_reactions_count.rb b/plugins/discourse-reactions/db/migrate/20220112091339_reset_erroneous_like_reactions_count.rb new file mode 100644 index 00000000000..d2ec620d9dd --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20220112091339_reset_erroneous_like_reactions_count.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class ResetErroneousLikeReactionsCount < ActiveRecord::Migration[6.1] + def up + like_reaction = DB.query_single(<<~SQL).first || "heart" + SELECT value + FROM site_settings + WHERE name = 'discourse_reactions_reaction_for_like' + SQL + + # the model does this gsub + # https://github.com/discourse/discourse-reactions/blob/10505af498ae99b6acc704bff6eb072bbffc2ade/app/models/discourse_reactions/reaction.rb#L25 + like_reaction = like_reaction.gsub("-", "") + + # AR enum in the Reaction model + emoji_reaction_type = 0 + + inconsistent_reactions = + DB.query(<<~SQL, like_reaction: like_reaction, emoji_reaction_type: emoji_reaction_type) + SELECT id + FROM discourse_reactions_reactions + WHERE + reaction_type = :emoji_reaction_type AND + reaction_value = :like_reaction AND + reaction_users_count IS NOT NULL + SQL + + return if inconsistent_reactions.size == 0 + ids = inconsistent_reactions.map(&:id) + + DB.exec(<<~SQL, ids: ids) + DELETE FROM discourse_reactions_reaction_users + WHERE reaction_id IN (:ids) + SQL + + DB.exec(<<~SQL, ids: ids) + UPDATE discourse_reactions_reactions + SET reaction_users_count = NULL + WHERE id IN (:ids) + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/discourse-reactions/db/migrate/20220201162748_rename_thumbsup_reactions.rb b/plugins/discourse-reactions/db/migrate/20220201162748_rename_thumbsup_reactions.rb new file mode 100644 index 00000000000..ecf874ac8a8 --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20220201162748_rename_thumbsup_reactions.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class RenameThumbsupReactions < ActiveRecord::Migration[6.1] + def up + current_reactions = + DB.query_single( + "SELECT value FROM site_settings WHERE name = 'discourse_reactions_enabled_reactions'", + )[ + 0 + ] + + alias_name = "thumbsup" + original_name = "+1" + + if current_reactions + updated_reactions = current_reactions.gsub(alias_name, original_name) + + DB.exec(<<~SQL, updated_reactions: updated_reactions) + UPDATE site_settings + SET value = :updated_reactions + WHERE name = 'discourse_reactions_enabled_reactions' + SQL + end + + has_both_reactions = DB.query_single(<<~SQL, alias: alias_name, new_value: original_name) + SELECT post_id + FROM discourse_reactions_reactions + WHERE reaction_value IN (:alias, :new_value) + GROUP BY post_id + HAVING COUNT(post_id) > 1 + SQL + + if has_both_reactions.present? + reaction_ids = DB.exec(<<~SQL, conflicts: has_both_reactions, alias: alias_name) + DELETE FROM discourse_reactions_reactions + WHERE post_id IN (:conflicts) AND reaction_value = :alias + RETURNING id + SQL + + DB.exec(<<~SQL, deleted_reactions: reaction_ids) + DELETE FROM discourse_reactions_reaction_users + WHERE reaction_id IN (:deleted_reactions) + SQL + end + + DB.exec(<<~SQL, alias: alias_name, new_value: original_name, conflicts: has_both_reactions) + UPDATE discourse_reactions_reactions + SET reaction_value = :new_value + WHERE reaction_value = :alias AND post_id NOT IN (:conflicts) + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/discourse-reactions/db/migrate/20221122010538_rename_badge.rb b/plugins/discourse-reactions/db/migrate/20221122010538_rename_badge.rb new file mode 100644 index 00000000000..f9d70b20ad0 --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20221122010538_rename_badge.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class RenameBadge < ActiveRecord::Migration[6.1] + TRANSLATIONS = { + "ar" => "أول تفاعل", + "de" => "Erste Reaktion", + "es" => "Primera reacción", + "fa_IR" => "اولین واکنش", + "fi" => "Ensimmäinen reaktio", + "fr" => "Première réaction", + "he" => "תחושה ראשונה", + "hu" => "Első reakció", + "it" => "Prima reazione", + "ja" => "最初のリアクション", + "pl_PL" => "Pierwsza reakcja", + "pt" => "Primeira Reação", + "pt_BR" => "Primeira Reação", + "ru" => "Первая реакция", + "sv" => "Första reaktionen", + "zh_CN" => "首次回应", + "zh_TW" => "頭一個反應", + } + + def up + default_locale = + DB.query_single("SELECT value FROM site_settings WHERE name = 'default_locale'").first || "en" + default_badge_name = "First Reaction" + badge_name = TRANSLATIONS.fetch(default_locale, default_badge_name) + + if badge_name != default_badge_name + default_badge_id = + DB.query_single("SELECT id FROM badges WHERE name = :name", name: default_badge_name).first + + if default_badge_id + DB.exec("DELETE FROM badges WHERE id = :id", id: default_badge_id) + DB.exec("DELETE FROM user_badges WHERE badge_id = :id", id: default_badge_id) + end + end + + sql = <<~SQL + UPDATE badges + SET name = :new_name, + description = NULL, + long_description = NULL + WHERE name = :old_name + SQL + + DB.exec(sql, old_name: badge_name, new_name: default_badge_name) + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/discourse-reactions/db/migrate/20230227050149_update_reaction_badge_icon.rb b/plugins/discourse-reactions/db/migrate/20230227050149_update_reaction_badge_icon.rb new file mode 100644 index 00000000000..ae302bf12cd --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20230227050149_update_reaction_badge_icon.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateReactionBadgeIcon < ActiveRecord::Migration[7.0] + def change + execute "UPDATE badges SET icon = 'smile' WHERE name = 'First Reaction' and icon = 'far-smile'" + end +end diff --git a/plugins/discourse-reactions/db/migrate/20240521032001_disable_reactions_like_sync_for_existing_sites.rb b/plugins/discourse-reactions/db/migrate/20240521032001_disable_reactions_like_sync_for_existing_sites.rb new file mode 100644 index 00000000000..eb20e8f2c25 --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20240521032001_disable_reactions_like_sync_for_existing_sites.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class DisableReactionsLikeSyncForExistingSites < ActiveRecord::Migration[7.0] + def up + # 5 is bool data_type + execute <<~SQL if Migration::Helpers.existing_site? + INSERT INTO site_settings(name, data_type, value, created_at, updated_at) + VALUES('discourse_reactions_like_sync_enabled', 5, 'f', NOW(), NOW()) + ON CONFLICT (name) DO NOTHING + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/discourse-reactions/db/migrate/20241025133536_alter_reaction_ids_to_bigint.rb b/plugins/discourse-reactions/db/migrate/20241025133536_alter_reaction_ids_to_bigint.rb new file mode 100644 index 00000000000..2b8b195b049 --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20241025133536_alter_reaction_ids_to_bigint.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AlterReactionIdsToBigint < ActiveRecord::Migration[7.1] + def up + change_column :discourse_reactions_reaction_users, :reaction_id, :bigint + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/discourse-reactions/db/migrate/20250205104150_remap_deprecated_icon_names_for_badge_fixtures.rb b/plugins/discourse-reactions/db/migrate/20250205104150_remap_deprecated_icon_names_for_badge_fixtures.rb new file mode 100644 index 00000000000..a9ac6f8e0fb --- /dev/null +++ b/plugins/discourse-reactions/db/migrate/20250205104150_remap_deprecated_icon_names_for_badge_fixtures.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +class RemapDeprecatedIconNamesForBadgeFixtures < ActiveRecord::Migration[7.2] + def up + execute <<~SQL + WITH remaps AS ( + SELECT 'smile' AS from_icon, 'face-smile' AS to_icon + ) + UPDATE badges + SET icon = remaps.to_icon + FROM remaps + WHERE icon = remaps.from_icon; + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/discourse-reactions/lib/discourse_reactions/guardian_extension.rb b/plugins/discourse-reactions/lib/discourse_reactions/guardian_extension.rb new file mode 100644 index 00000000000..e2a9c9fbaee --- /dev/null +++ b/plugins/discourse-reactions/lib/discourse_reactions/guardian_extension.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module DiscourseReactions::GuardianExtension + def can_delete_reaction_user?(reaction_user) + reaction_user.can_undo? + end +end diff --git a/plugins/discourse-reactions/lib/discourse_reactions/migration_report.rb b/plugins/discourse-reactions/lib/discourse_reactions/migration_report.rb new file mode 100644 index 00000000000..c73882d1eee --- /dev/null +++ b/plugins/discourse-reactions/lib/discourse_reactions/migration_report.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module DiscourseReactions + # TODO (martin) Remove this once we have rolled out reactions like sync more widely. + class MigrationReport + def self.run(previous_report_data: {}, print_report: true) + report_data = { + topic_user_liked: TopicUser.where(liked: true).count, + user_actions_liked: UserAction.where(action_type: UserAction::LIKE).count, + user_actions_was_liked: UserAction.where(action_type: UserAction::WAS_LIKED).count, + post_action_likes: + PostAction.where(post_action_type_id: PostActionType::LIKE_POST_ACTION_ID).count, + post_like_count_total: Post.sum(:like_count), + topic_like_count_total: Topic.sum(:like_count), + user_stat_likes_given_total: UserStat.sum(:likes_given), + user_stat_likes_received_total: UserStat.sum(:likes_received), + given_daily_like_total: GivenDailyLike.sum(:likes_given), + badge_count: UserBadge.count, + reactions_total: DiscourseReactions::ReactionUser.count, + } + + puts humanized_report_data(report_data, previous_report_data) if print_report + + report_data + end + + def self.generate_diff(report_data, previous_report_data) + report_data_diff_indicators = {} + + previous_report_data.each do |key, value| + diff_indicator = + if report_data[key] < value + " (-#{value - report_data[key]})" + elsif report_data[key] > value + " (+#{report_data[key] - value})" + else + " (no change)" + end + + report_data_diff_indicators[key] = diff_indicator + end + + report_data_diff_indicators + end + + def self.humanized_report_data(report_data, previous_report_data = {}) + report_data_diff_indicators = generate_diff(report_data, previous_report_data) + + report_data_reactions_breakdown = {} + SiteSetting + .discourse_reactions_enabled_reactions + .split("|") + .each do |reaction| + report_data_reactions_breakdown[reaction] = DiscourseReactions::Reaction.where( + reaction_value: reaction, + ).sum(:reaction_users_count) + end + + <<~REPORT + Reaction migration report: + ------------------------------------------------------------ + + main_reaction_id: #{DiscourseReactions::Reaction.main_reaction_id} + discourse_reactions_like_sync_enabled: #{SiteSetting.discourse_reactions_like_sync_enabled} + discourse_reactions_enabled_reactions: #{SiteSetting.discourse_reactions_enabled_reactions} + discourse_reactions_excluded_from_like: #{SiteSetting.discourse_reactions_excluded_from_like} + + PostAction likes: #{report_data[:post_action_likes]}#{report_data_diff_indicators[:post_action_likes]} + UserActions.liked: #{report_data[:user_actions_liked]}#{report_data_diff_indicators[:user_actions_liked]} + UserActions.was_liked: #{report_data[:user_actions_was_liked]}#{report_data_diff_indicators[:user_actions_was_liked]} + UserStat.likes_given: #{report_data[:user_stat_likes_given_total]}#{report_data_diff_indicators[:user_stat_likes_given_total]} + UserStat.likes_received: #{report_data[:user_stat_likes_received_total]}#{report_data_diff_indicators[:user_stat_likes_received_total]} + Post.like_count: #{report_data[:post_like_count_total]}#{report_data_diff_indicators[:post_like_count_total]} + Topic.like_count: #{report_data[:topic_like_count_total]}#{report_data_diff_indicators[:topic_like_count_total]} + TopicUser.liked: #{report_data[:topic_user_liked]}#{report_data_diff_indicators[:topic_user_liked]} + Badge count: #{report_data[:badge_count]}#{report_data_diff_indicators[:badge_count]} + Given daily like total: #{report_data[:given_daily_like_total]}#{report_data_diff_indicators[:given_daily_like_total]} + Reactions: #{report_data[:reactions_total]}#{report_data_diff_indicators[:reactions_total]} + #{ + report_data_reactions_breakdown + .map { |reaction, count| " -> #{reaction}: #{count}" } + .join("\n") + } + + REPORT + end + end +end diff --git a/plugins/discourse-reactions/lib/discourse_reactions/notification_extension.rb b/plugins/discourse-reactions/lib/discourse_reactions/notification_extension.rb new file mode 100644 index 00000000000..90f1f26cc4a --- /dev/null +++ b/plugins/discourse-reactions/lib/discourse_reactions/notification_extension.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module DiscourseReactions::NotificationExtension + def types + @types_with_reaction ||= super.merge(reaction: 25) + end +end diff --git a/plugins/discourse-reactions/lib/discourse_reactions/post_action_extension.rb b/plugins/discourse-reactions/lib/discourse_reactions/post_action_extension.rb new file mode 100644 index 00000000000..a8e2e2770a5 --- /dev/null +++ b/plugins/discourse-reactions/lib/discourse_reactions/post_action_extension.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module DiscourseReactions::PostActionExtension + def self.prepended(base) + base.has_one :reaction_user, + ->(post_action) { where(user_id: post_action.user_id) }, + foreign_key: :post_id, + primary_key: :post_id, + class_name: "DiscourseReactions::ReactionUser" + base.has_one :reaction, class_name: "DiscourseReactions::Reaction", through: :reaction_user + end + + def self.filter_reaction_likes_sql + <<~SQL + post_actions.post_action_type_id = :like + AND post_actions.deleted_at IS NULL + AND post_actions.post_id NOT IN ( + #{post_action_with_reaction_user_sql} + ) + SQL + end + + def self.post_action_with_reaction_user_sql + <<~SQL + SELECT discourse_reactions_reaction_users.post_id + FROM discourse_reactions_reaction_users + INNER JOIN discourse_reactions_reactions ON discourse_reactions_reactions.id = discourse_reactions_reaction_users.reaction_id + WHERE discourse_reactions_reaction_users.user_id = post_actions.user_id + AND discourse_reactions_reaction_users.post_id = post_actions.post_id + AND discourse_reactions_reactions.reaction_value IN (:valid_reactions) + SQL + end +end diff --git a/plugins/discourse-reactions/lib/discourse_reactions/post_alerter_extension.rb b/plugins/discourse-reactions/lib/discourse_reactions/post_alerter_extension.rb new file mode 100644 index 00000000000..2410319d37c --- /dev/null +++ b/plugins/discourse-reactions/lib/discourse_reactions/post_alerter_extension.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DiscourseReactions::PostAlerterExtension + def should_notify_previous?(user, post, notification, opts) + super || + ( + notification.notification_type == Notification.types[:reaction] && + should_notify_like?(user, notification) + ) + end +end diff --git a/plugins/discourse-reactions/lib/discourse_reactions/post_extension.rb b/plugins/discourse-reactions/lib/discourse_reactions/post_extension.rb new file mode 100644 index 00000000000..7d1f9d07579 --- /dev/null +++ b/plugins/discourse-reactions/lib/discourse_reactions/post_extension.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module DiscourseReactions::PostExtension + attr_accessor :post_actions_with_reaction_users + + def self.prepended(base) + base.has_many :reactions, class_name: "DiscourseReactions::Reaction" + base.has_many :reactions_user, class_name: "DiscourseReactions::ReactionUser" + base.attr_accessor :user_positively_reacted, :reaction_users_count + end + + def emoji_reactions + @emoji_reactions ||= + begin + self.reactions.select { |reaction| Emoji.exists?(reaction.reaction_value) } + end + end +end diff --git a/plugins/discourse-reactions/lib/discourse_reactions/posts_reaction_loader.rb b/plugins/discourse-reactions/lib/discourse_reactions/posts_reaction_loader.rb new file mode 100644 index 00000000000..7098a7cd5f0 --- /dev/null +++ b/plugins/discourse-reactions/lib/discourse_reactions/posts_reaction_loader.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DiscourseReactions::PostsReactionLoader + def posts_with_reactions + if SiteSetting.discourse_reactions_enabled + posts = object.posts.includes(:post_actions, reactions: { reaction_users: :user }) + post_ids = posts.map(&:id).uniq + posts_reaction_users_count = TopicViewSerializer.posts_reaction_users_count(post_ids) + post_actions_with_reaction_users = + DiscourseReactions::TopicViewSerializerExtension.load_post_action_reaction_users_for_posts( + post_ids, + ) + posts.each do |post| + post.reaction_users_count = posts_reaction_users_count[post.id].to_i + post.post_actions_with_reaction_users = post_actions_with_reaction_users[post.id] || {} + end + + object.instance_variable_set(:@posts, posts) + end + end +end diff --git a/plugins/discourse-reactions/lib/discourse_reactions/topic_view_posts_serializer_extension.rb b/plugins/discourse-reactions/lib/discourse_reactions/topic_view_posts_serializer_extension.rb new file mode 100644 index 00000000000..ba5f0c7f125 --- /dev/null +++ b/plugins/discourse-reactions/lib/discourse_reactions/topic_view_posts_serializer_extension.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module DiscourseReactions::TopicViewPostsSerializerExtension + include DiscourseReactions::PostsReactionLoader + + def posts + posts_with_reactions + super + end +end diff --git a/plugins/discourse-reactions/lib/discourse_reactions/topic_view_serializer_extension.rb b/plugins/discourse-reactions/lib/discourse_reactions/topic_view_serializer_extension.rb new file mode 100644 index 00000000000..0dd9517a2b9 --- /dev/null +++ b/plugins/discourse-reactions/lib/discourse_reactions/topic_view_serializer_extension.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module DiscourseReactions::TopicViewSerializerExtension + include DiscourseReactions::PostsReactionLoader + + def self.load_post_action_reaction_users_for_posts(post_ids) + PostAction + .joins( + "LEFT 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_id: post_ids) + .where("post_actions.deleted_at IS NULL") + .where(post_action_type_id: PostActionType::LIKE_POST_ACTION_ID) + .where( + "post_actions.post_id IN (#{DiscourseReactions::PostActionExtension.post_action_with_reaction_user_sql})", + valid_reactions: DiscourseReactions::Reaction.reactions_counting_as_like, + ) + .reduce({}) do |hash, post_action| + hash[post_action.post_id] ||= {} + hash[post_action.post_id][post_action.id] = post_action + hash + end + end + + def posts + posts_with_reactions + super + end + + def self.prepended(base) + def base.posts_reaction_users_count(post_ids) + posts_reaction_users_count_query = + DB.query( + <<~SQL, + SELECT union_subquery.post_id, COUNT(DISTINCT(union_subquery.user_id)) FROM ( + SELECT user_id, post_id FROM post_actions + WHERE post_id IN (:post_ids) + AND post_action_type_id = :like_id + AND deleted_at IS NULL + UNION ALL + SELECT discourse_reactions_reaction_users.user_id, posts.id from posts + LEFT JOIN discourse_reactions_reactions ON discourse_reactions_reactions.post_id = posts.id + LEFT JOIN discourse_reactions_reaction_users ON discourse_reactions_reaction_users.reaction_id = discourse_reactions_reactions.id + WHERE posts.id IN (:post_ids) + ) AS union_subquery WHERE union_subquery.post_ID IS NOT NULL GROUP BY union_subquery.post_id + SQL + post_ids: Array.wrap(post_ids), + like_id: PostActionType::LIKE_POST_ACTION_ID, + ) + + posts_reaction_users_count_query.each_with_object({}) do |row, hash| + hash[row.post_id] = row.count + end + end + end +end diff --git a/plugins/discourse-reactions/lib/reaction_for_like_site_setting_enum.rb b/plugins/discourse-reactions/lib/reaction_for_like_site_setting_enum.rb new file mode 100644 index 00000000000..8591867fbc6 --- /dev/null +++ b/plugins/discourse-reactions/lib/reaction_for_like_site_setting_enum.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_dependency "enum_site_setting" + +class ReactionForLikeSiteSettingEnum < EnumSiteSetting + def self.valid_value?(val) + values.any? { |v| v[:value] == val } + end + + def self.values + @values = + begin + excluded_from_like = SiteSetting.discourse_reactions_excluded_from_like.to_s.split("|") + + reactions = + DiscourseReactions::Reaction + .valid_reactions + .map { |reaction| { name: reaction, value: reaction } } + .reject { |reaction| excluded_from_like.include?(reaction[:value]) } + end + end +end diff --git a/plugins/discourse-reactions/lib/reactions_excluded_from_like_site_setting_validator.rb b/plugins/discourse-reactions/lib/reactions_excluded_from_like_site_setting_validator.rb new file mode 100644 index 00000000000..5d0b8d84560 --- /dev/null +++ b/plugins/discourse-reactions/lib/reactions_excluded_from_like_site_setting_validator.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ReactionsExcludedFromLikeSiteSettingValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(val) + val.blank? || valid_emojis?(val) + end + + def error_message + I18n.t("site_settings.errors.invalid_excluded_emoji") + end + + def valid_emojis?(val) + emojis = val.to_s.split("|") + enabled_reaction_emojis = SiteSetting.discourse_reactions_enabled_reactions.to_s.split("|") + !emojis.include?(SiteSetting.discourse_reactions_reaction_for_like) && + emojis.all? do |emoji| + enabled_reaction_emojis.include?(emoji) || + emoji == SiteSetting.defaults[:discourse_reactions_excluded_from_like] + end + end +end diff --git a/plugins/discourse-reactions/lib/tasks/reactions.rake b/plugins/discourse-reactions/lib/tasks/reactions.rake new file mode 100644 index 00000000000..e5d3daf8ae9 --- /dev/null +++ b/plugins/discourse-reactions/lib/tasks/reactions.rake @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +def words_list + @words_list ||= [ + "etsy", + "twee", + "hoodie", + "Banksy", + "retro", + "synth", + "single-origin", + "coffee", + "art", + "party", + "cliche", + "artisan", + "Williamsburg", + "squid", + "helvetica", + "keytar", + "American Apparel", + "craft beer", + "food truck", + "you probably haven't heard of them", + "cardigan", + "aesthetic", + "raw denim", + "sartorial", + "gentrify", + "lomo", + "Vice", + "Pitchfork", + "Austin", + "sustainable", + "salvia", + "organic", + "thundercats", + "PBR", + "iPhone", + "lo-fi", + "skateboard", + "jean shorts", + "next level", + "beard", + "tattooed", + "trust fund", + "Four Loko", + "master cleanse", + "ethical", + "high life", + "wolf moon", + "fanny pack", + "Terry Richardson", + "8-bit", + "Carles", + "Shoreditch", + "seitan", + "freegan", + "keffiyeh", + "biodiesel", + "quinoa", + "farm-to-table", + "fixie", + "viral", + "chambray", + "scenester", + "leggings", + "readymade", + "Brooklyn", + "Wayfarers", + "Marfa", + "put a bird on it", + "dreamcatcher", + "photo booth", + "tofu", + "mlkshk", + "vegan", + "vinyl", + "DIY", + "banh mi", + "bicycle rights", + "before they sold out", + "gluten-free", + "yr butcher blog", + "whatever", + "Cosby Sweater", + "VHS", + "messenger bag", + "cred", + "locavore", + "mustache", + "tumblr", + "Portland", + "mixtape", + "fap", + "letterpress", + "McSweeney's", + "stumptown", + "brunch", + "Wes Anderson", + "irony", + "echo park", + ] +end + +def generate_email + email = words_list.sample.delete(" ") + "@" + words_list.sample.delete(" ") + ".com" + email.delete("'").force_encoding("UTF-8") +end + +def create_user(user_email) + user = User.find_by_email(user_email) + unless user + puts "Creating new account: #{user_email}" + user = + User.create!( + email: user_email, + password: SecureRandom.hex, + username: UserNameSuggester.suggest(user_email), + ) + end + user.active = true + user.save! + user +end + +desc "create users and generate random reactions on a post" +task "reactions:generate", %i[post_id reactions_count reaction] => [:environment] do |_, args| + if !Rails.env.development? + raise "rake reactions:generate should only be run in RAILS_ENV=development, as you are creating fake reactions to posts" + end + + post_id = args[:post_id] + + return if !post_id + + post = Post.find_by(id: post_id) + + return if !post + + reactions_count = args[:reactions_count] ? args[:reactions_count].to_i : 10 + + reactions_count.times do + reaction = args[:reaction] || DiscourseReactions::Reaction.valid_reactions.to_a.sample + user = create_user(generate_email) + + puts "Reaction to post #{post.id} with reaction: #{reaction}" + DiscourseReactions::ReactionManager.new( + reaction_value: reaction, + user: user, + post: post, + ).toggle! + end +end diff --git a/plugins/discourse-reactions/plugin.rb b/plugins/discourse-reactions/plugin.rb new file mode 100644 index 00000000000..d28f8fac0b6 --- /dev/null +++ b/plugins/discourse-reactions/plugin.rb @@ -0,0 +1,457 @@ +# 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.rb" +require_relative "lib/reactions_excluded_from_like_site_setting_validator.rb" + +after_initialize do + SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-reactions", "db", "fixtures").to_s + + module ::DiscourseReactions + PLUGIN_NAME = "discourse-reactions" + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace DiscourseReactions + end + end + + %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: "/" } + + DiscourseReactions::Engine.routes.draw do + get "/discourse-reactions/custom-reactions" => "custom_reactions#index", + :constraints => { + format: :json, + } + put "/discourse-reactions/posts/:post_id/custom-reactions/:reaction/toggle" => + "custom_reactions#toggle", + :constraints => { + format: :json, + } + get "/discourse-reactions/posts/reactions" => "custom_reactions#reactions_given", + :as => "reactions_given" + get "/discourse-reactions/posts/reactions-received" => "custom_reactions#reactions_received", + :as => "reactions_received" + get "/discourse-reactions/posts/:id/reactions-users" => "custom_reactions#post_reactions_users", + :as => "post_reactions_users" + end + + 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| + DiscourseReactions::Reaction.reactions_counting_as_like.include?(reaction.reaction_value) && + reaction.reaction_users.find { |ru| ru.user_id == scope.user.id }.present? + 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 diff --git a/plugins/discourse-reactions/spec/fabricators/reaction_fabricator.rb b/plugins/discourse-reactions/spec/fabricators/reaction_fabricator.rb new file mode 100644 index 00000000000..cdc744cfdb0 --- /dev/null +++ b/plugins/discourse-reactions/spec/fabricators/reaction_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:reaction, class_name: "DiscourseReactions::Reaction") do + post { |attrs| attrs[:post] } + reaction_type 0 + reaction_value "otter" +end diff --git a/plugins/discourse-reactions/spec/fabricators/reaction_notification_fabricator.rb b/plugins/discourse-reactions/spec/fabricators/reaction_notification_fabricator.rb new file mode 100644 index 00000000000..b88c5664930 --- /dev/null +++ b/plugins/discourse-reactions/spec/fabricators/reaction_notification_fabricator.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +Fabricator(:one_reaction_notification, from: :notification) do + transient :acting_user + notification_type Notification.types[:reaction] + post + topic { |attrs| attrs[:post].topic } + data do |attrs| + acting_user = attrs[:acting_user] || Fabricate(:user) + { + topic_title: attrs[:topic].title, + original_post_id: attrs[:post].id, + original_post_type: attrs[:post].post_type, + original_username: acting_user.username, + display_name: acting_user.name, + revision_number: nil, + display_username: acting_user.username, + }.to_json + end +end + +Fabricator(:multiple_reactions_notification, from: :one_reaction_notification) do + transient :acting_user_2 + transient :count + data do |attrs| + acting_user = attrs[:acting_user] || Fabricate(:user) + acting_user_2 = attrs[:acting_user_2] || Fabricate(:user) + { + topic_title: attrs[:topic].title, + original_post_id: attrs[:post].id, + original_post_type: attrs[:post].post_type, + original_username: acting_user_2.username, + revision_number: nil, + display_username: acting_user_2.username, + display_name: acting_user_2.name, + previous_notification_id: 2019, + username2: acting_user.username, + name2: acting_user.name, + count: attrs[:count] || 2, + }.to_json + end +end diff --git a/plugins/discourse-reactions/spec/fabricators/reaction_user_fabricator.rb b/plugins/discourse-reactions/spec/fabricators/reaction_user_fabricator.rb new file mode 100644 index 00000000000..6da0ca608f9 --- /dev/null +++ b/plugins/discourse-reactions/spec/fabricators/reaction_user_fabricator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +Fabricator(:reaction_user, class_name: "DiscourseReactions::ReactionUser") do + transient :skip_post_action + reaction { |attrs| attrs[:reaction] } + user { |attrs| attrs[:user] || Fabricate(:user) } + post { |attrs| attrs[:post] || Fabricate(:post) } + + after_create do |reaction_user, transients| + if DiscourseReactions::Reaction.reactions_counting_as_like.include?( + reaction_user.reaction.reaction_value, + ) && !transients[:skip_post_action] + Fabricate( + :post_action, + user: reaction_user.user, + post: reaction_user.post, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + created_at: reaction_user.created_at, + ) + end + end +end diff --git a/plugins/discourse-reactions/spec/lib/reaction_for_like_site_setting_enum_spec.rb b/plugins/discourse-reactions/spec/lib/reaction_for_like_site_setting_enum_spec.rb new file mode 100644 index 00000000000..6257bb5dac2 --- /dev/null +++ b/plugins/discourse-reactions/spec/lib/reaction_for_like_site_setting_enum_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.describe ReactionForLikeSiteSettingEnum do + it "does not allow using any discourse_reactions_excluded_from_like emojis" do + expect(described_class.valid_value?("-1")).to eq(false) + expect(described_class.valid_value?("heart")).to eq(true) + expect(described_class.valid_value?("clap")).to eq(true) + end + + it "only allows using discourse_reactions_enabled_reactions or the default" do + SiteSetting.discourse_reactions_enabled_reactions = "clap|laughing" + expect(described_class.valid_value?("heart")).to eq(true) + expect(described_class.valid_value?("clap")).to eq(true) + expect(described_class.valid_value?("tickets")).to eq(false) + end +end diff --git a/plugins/discourse-reactions/spec/lib/reactions_excluded_from_like_site_setting_validator_spec.rb b/plugins/discourse-reactions/spec/lib/reactions_excluded_from_like_site_setting_validator_spec.rb new file mode 100644 index 00000000000..92bcf9a46e3 --- /dev/null +++ b/plugins/discourse-reactions/spec/lib/reactions_excluded_from_like_site_setting_validator_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.describe ReactionsExcludedFromLikeSiteSettingValidator do + it "does not allow the value of discourse_reactions_reaction_for_like to be used" do + expect(described_class.new.valid_value?("clap|heart")).to eq(false) + expect(described_class.new.valid_value?("clap")).to eq(true) + end + + it "does not allow any emojis not in discourse_reactions_enabled_reactions to be used except the default" do + SiteSetting.discourse_reactions_enabled_reactions = "laughing|open_mouth" + expect(described_class.new.valid_value?("clap")).to eq(false) + expect(described_class.new.valid_value?("laughing")).to eq(true) + expect(described_class.new.valid_value?("-1")).to eq(true) + expect(described_class.new.valid_value?("-1|laughing")).to eq(true) + end +end diff --git a/plugins/discourse-reactions/spec/models/post_mover_spec.rb b/plugins/discourse-reactions/spec/models/post_mover_spec.rb new file mode 100644 index 00000000000..ff6b0f2c119 --- /dev/null +++ b/plugins/discourse-reactions/spec/models/post_mover_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +describe PostMover do + fab!(:admin) + fab!(:user) + fab!(:topic_1) { Fabricate(:topic, user: user) } + fab!(:topic_2) { Fabricate(:topic, user: user) } + fab!(:topic_3) { Fabricate(:topic, user: user) } + fab!(:post_1) { Fabricate(:post, topic: topic_1, user: user) } + fab!(:post_2) { Fabricate(:post, topic: topic_1, user: user) } + fab!(:reaction_1) { Fabricate(:reaction, post: post_1, reaction_value: "clap") } + fab!(:reaction_2) { Fabricate(:reaction, post: post_1, reaction_value: "confetti") } + fab!(:reaction_3) { Fabricate(:reaction, post: post_2, reaction_value: "heart") } + fab!(:reaction_4) { Fabricate(:reaction, post: post_2, reaction_value: "wave") } + fab!(:user_reaction_1) { Fabricate(:reaction_user, reaction: reaction_1, post: post_1) } + fab!(:user_reaction_2) { Fabricate(:reaction_user, reaction: reaction_2, post: post_1) } + fab!(:user_reaction_3) { Fabricate(:reaction_user, reaction: reaction_3, post: post_2) } + fab!(:user_reaction_4) { Fabricate(:reaction_user, reaction: reaction_4, post: post_2) } + + before { SiteSetting.discourse_reactions_enabled = true } + + it "should create new post when topic's first post has no reactions" do + old_topic = Fabricate(:topic) + new_topic = Fabricate(:topic) + post = Fabricate(:post, topic: old_topic) + + post_mover = PostMover.new(old_topic, Discourse.system_user, [post.id]) + expect { post_mover.to_topic(new_topic) }.to change { new_topic.posts.count }.by(1) + end + + it "should create new post when first post has likes but no emoji reaction user" do + old_topic = Fabricate(:topic) + new_topic = Fabricate(:topic) + post = Fabricate(:post, topic: old_topic) + Fabricate(:reaction, post: post, reaction_value: "heart") + + PostActionCreator.create(user, post, :like) + + expect(post.reload.like_count).to eq(1) + + post_mover = PostMover.new(old_topic, Discourse.system_user, [post.id]) + expect { post_mover.to_topic(new_topic) }.to change { new_topic.posts.count }.by(1) + + new_post = new_topic.first_post + expect(new_post.like_count).to eq(1) + expect(new_post.reactions.count).to eq(1) + expect(new_post.reactions_user.count).to eq(0) + end + + xit "should add old post's reactions to new post when a topic's first post is moved" do + expect(post_1.reactions).to contain_exactly(reaction_1, reaction_2) + expect(topic_2.posts.count).to eq(0) + + post_mover = PostMover.new(topic_1, Discourse.system_user, [post_1.id]) + expect { post_mover.to_topic(topic_2) }.to change { topic_2.posts.count }.by(1) + + expect(topic_2.posts.count).to eq(1) + + new_post = topic_2.first_post + reaction_emojis = new_post.reactions.pluck(:reaction_value) + + expect(reaction_emojis.count).to eq(2) + expect(reaction_emojis).to match_array([reaction_1.reaction_value, reaction_2.reaction_value]) + + reaction_user_ids = new_post.reactions_user.pluck(:user_id) + expect(reaction_user_ids.count).to eq(2) + expect(reaction_user_ids).to match_array([user_reaction_1.user_id, user_reaction_2.user_id]) + end + + it "should retain existing reactions after moving a post" do + expect(post_2.reactions).to contain_exactly(reaction_3, reaction_4) + expect(topic_3.posts.count).to eq(0) + + post_mover = PostMover.new(topic_1, Discourse.system_user, [post_2.id]) + expect { post_mover.to_topic(topic_3) }.to change { topic_3.posts.count }.by(1) + + new_post = topic_3.first_post + + # reaction id does not change as post is updated (unlike first post in topic) + expect(new_post.reactions.count).to eq(2) + expect(new_post.reactions).to match_array([reaction_3, reaction_4]) + + # reaction_user id does not change as post is updated (unlike first post in topic) + expect(new_post.reactions_user.count).to eq(2) + expect(new_post.reactions_user).to match_array([user_reaction_3, user_reaction_4]) + end +end diff --git a/plugins/discourse-reactions/spec/models/reaction_spec.rb b/plugins/discourse-reactions/spec/models/reaction_spec.rb new file mode 100644 index 00000000000..6f708cc8c31 --- /dev/null +++ b/plugins/discourse-reactions/spec/models/reaction_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" +require_relative "../fabricators/reaction_fabricator.rb" + +describe DiscourseReactions::Reaction do + before { SiteSetting.discourse_reactions_enabled = true } + + it "knows which reactions are valid" do + SiteSetting.discourse_reactions_enabled_reactions = "laughing|heart|open_mouth|cry|angry|+1|-1" + expect(described_class.valid_reactions).to eq( + %w[laughing heart open_mouth cry angry +1 -1].to_set, + ) + end + + it "knows the main reaction" do + SiteSetting.discourse_reactions_reaction_for_like = "+1" + expect(described_class.main_reaction_id).to eq("+1") + end + + it "knows the reactions that count as a like, that are not the main reaction" do + SiteSetting.discourse_reactions_enabled_reactions = "laughing|heart|open_mouth|cry|angry|+1|-1" + SiteSetting.discourse_reactions_reaction_for_like = "+1" + SiteSetting.discourse_reactions_excluded_from_like = "angry|-1" + expect(described_class.reactions_counting_as_like).to eq( + %w[laughing heart open_mouth cry].to_set, + ) + end +end diff --git a/plugins/discourse-reactions/spec/models/reaction_user_spec.rb b/plugins/discourse-reactions/spec/models/reaction_user_spec.rb new file mode 100644 index 00000000000..d024f4fee3f --- /dev/null +++ b/plugins/discourse-reactions/spec/models/reaction_user_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" +require_relative "../fabricators/reaction_fabricator.rb" +require_relative "../fabricators/reaction_user_fabricator.rb" + +describe DiscourseReactions::ReactionUser do + before { SiteSetting.discourse_reactions_enabled = true } + + describe "delegating methods when the user is nil" do + let(:reaction_user) { described_class.new(user: nil) } + + it "returns nil when delegating the username method with a nil user" do + expect(reaction_user.username).to be_nil + end + + it "returns nil when delegating the avatar_template method with a nil user" do + expect(reaction_user.avatar_template).to be_nil + end + end + + describe "when a user gets deleted" do + it "deletes all the reactions for that user" do + user = Fabricate(:user) + reaction = Fabricate(:reaction) + post = Fabricate(:post) + user_reaction = Fabricate(:reaction_user, user: user, reaction: reaction, post: post) + + user.destroy! + reaction_users = described_class.where(user_id: user.id) + + expect(reaction_users).to be_empty + end + end +end diff --git a/plugins/discourse-reactions/spec/plugin_spec.rb b/plugins/discourse-reactions/spec/plugin_spec.rb new file mode 100644 index 00000000000..85ad7ee26ee --- /dev/null +++ b/plugins/discourse-reactions/spec/plugin_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DiscourseReactions do + before do + SiteSetting.discourse_reactions_enabled = true + SiteSetting.discourse_reactions_like_sync_enabled = true + SiteSetting.discourse_reactions_excluded_from_like = "" + end + + fab!(:user_1) { Fabricate(:user, name: "Bruce Wayne I") } + fab!(:user_2) { Fabricate(:user, name: "Bruce Wayne II") } + fab!(:post) { Fabricate(:post, user: user_1) } + let(:clap_reaction) do + DiscourseReactions::ReactionManager.new( + reaction_value: "clap", + user: user_2, + post: post, + ).toggle! + end + + it "includes the display name in the reaction data" do + clap_reaction + + expect(clap_reaction.data).to include(user_2.name) + end + + describe "on_setting_change(discourse_reactions_excluded_from_like)" do + it "kicks off the background job to sync post actions when site setting changes" do + expect_enqueued_with(job: ::Jobs::DiscourseReactions::LikeSynchronizer) do + SiteSetting.discourse_reactions_excluded_from_like = "confetti_ball|-1" + end + end + end + + describe "user_action_stream_builder modifier for UserAction.stream" do + fab!(:post_2) { Fabricate(:post, user: user_1) } + + before do + UserActionManager.enable + SiteSetting.discourse_reactions_excluded_from_like = "-1" + end + + it "excludes WAS_LIKED records where there is an associated ReactionUser for the post and user" do + # user_2 reacted to user_1's post, which also counts as a like + clap_reaction + + # user_2 reacted to user_1's other post, which just counts as a + # regular like because it uses main_reaction_id, so it should + # show on the user action stream + DiscourseReactions::ReactionManager.new( + reaction_value: DiscourseReactions::Reaction.main_reaction_id, + user: user_2, + post: post_2, + ).toggle! + + user_actions = UserAction.stream({ user_id: user_1.id, guardian: user_1.guardian, limit: 10 }) + expect(user_actions.length).to eq(1) + expect(user_actions.first.action_type).to eq(UserAction::WAS_LIKED) + expect(user_actions.first.post_id).to eq(post_2.id) + expect(user_actions.first.target_user_id).to eq(user_1.id) + end + end +end diff --git a/plugins/discourse-reactions/spec/reports/reactions_spec.rb b/plugins/discourse-reactions/spec/reports/reactions_spec.rb new file mode 100644 index 00000000000..201bcb6c6ab --- /dev/null +++ b/plugins/discourse-reactions/spec/reports/reactions_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Report do + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:post_1) { Fabricate(:post) } + fab!(:post_2) { Fabricate(:post, user: user_1) } + + before do + SiteSetting.discourse_reactions_enabled = true + SiteSetting.discourse_reactions_enabled_reactions += "|cat" + end + + it "scopes the report to Like post action type" do + Fabricate( + :post_action, + post: post_1, + user: user_1, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + created_at: 1.day.ago, + ) + Fabricate( + :post_action, + post: post_1, + user: user_1, + post_action_type_id: PostActionType.types[:spam], + created_at: 1.day.ago, + ) + Fabricate( + :post_action, + post: post_2, + user: user_2, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + created_at: 1.day.ago, + ) + + report = Report.find("reactions", start_date: 2.days.ago, end_date: Time.current) + + post_action_data = report.data.find { |x| x[:day] === 1.day.ago.to_date } + expect(post_action_data[:like_count]).to eq(2) + end + + it "includes reactions on the start dates and end dates and does not double up Like count for reactions counting as likes" do + reaction_cat = Fabricate(:reaction, post: post_1, reaction_value: "cat") + Fabricate( + :reaction_user, + reaction: reaction_cat, + user: user_1, + post: post_1, + created_at: 2.days.ago, + ) + Fabricate( + :reaction_user, + reaction: reaction_cat, + user: user_2, + post: post_1, + created_at: Time.current, + ) + + report = Report.find("reactions", start_date: 2.days.ago, end_date: Time.current) + + expect(report.data).to contain_exactly( + a_hash_including("cat_count" => 1, :day => 2.days.ago.to_date, :like_count => 0), + a_hash_including(day: 1.days.ago.to_date, like_count: 0), + a_hash_including("cat_count" => 1, :day => Time.current.to_date, :like_count => 0), + ) + end + + it "does not count trashed post action likes" do + Fabricate( + :post_action, + post: post_1, + user: user_1, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + created_at: 1.day.ago, + ) + Fabricate( + :post_action, + post: post_2, + user: user_2, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + created_at: 1.day.ago, + deleted_at: 1.day.ago, + ) + + report = Report.find("reactions", start_date: 2.days.ago, end_date: Time.current) + + post_action_data = report.data.find { |x| x[:day] === 1.day.ago.to_date } + expect(post_action_data[:like_count]).to eq(1) + end +end diff --git a/plugins/discourse-reactions/spec/requests/custom_reactions_controller_spec.rb b/plugins/discourse-reactions/spec/requests/custom_reactions_controller_spec.rb new file mode 100644 index 00000000000..f6f3f12bedc --- /dev/null +++ b/plugins/discourse-reactions/spec/requests/custom_reactions_controller_spec.rb @@ -0,0 +1,627 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DiscourseReactions::CustomReactionsController do + fab!(:post_1) { Fabricate(:post) } + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user) } + fab!(:user_4) { Fabricate(:user) } + fab!(:user_5) { Fabricate(:user) } + fab!(:admin) + fab!(:post_2) { Fabricate(:post, user: user_1) } + fab!(:private_topic) { Fabricate(:private_message_topic, user: user_2, recipient: admin) } + fab!(:private_post) { Fabricate(:post, topic: private_topic) } + fab!(:whisper_post) do + Fabricate(:post, topic: Fabricate(:topic), post_type: Post.types[:whisper]) + end + fab!(:laughing_reaction) { Fabricate(:reaction, post: post_2, reaction_value: "laughing") } + fab!(:open_mouth_reaction) { Fabricate(:reaction, post: post_2, reaction_value: "open_mouth") } + fab!(:hugs_reaction) { Fabricate(:reaction, post: post_2, reaction_value: "hugs") } + fab!(:hugs_reaction_private) { Fabricate(:reaction, post: private_post, reaction_value: "hugs") } + fab!(:laughing_reaction_whisper) do + Fabricate(:reaction, post: whisper_post, reaction_value: "laughing") + end + fab!(:like) do + Fabricate( + :post_action, + post: post_2, + user: user_5, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + end + fab!(:reaction_user_1) do + Fabricate(:reaction_user, reaction: laughing_reaction, user: user_2, post: post_2) + end + fab!(:reaction_user_2) do + Fabricate(:reaction_user, reaction: laughing_reaction, user: user_1, post: post_2) + end + fab!(:reaction_user_3) do + Fabricate(:reaction_user, reaction: hugs_reaction, user: user_4, post: post_2) + end + fab!(:reaction_user_4) do + Fabricate(:reaction_user, reaction: open_mouth_reaction, user: user_3, post: post_2) + end + fab!(:reaction_user_5) do + Fabricate(:reaction_user, reaction: hugs_reaction_private, user: admin, post: private_post) + end + fab!(:reaction_user_6) do + Fabricate(:reaction_user, reaction: laughing_reaction_whisper, user: user_2, post: whisper_post) + end + + before do + SiteSetting.discourse_reactions_enabled = true + SiteSetting.discourse_reactions_like_icon = "heart" + SiteSetting.discourse_reactions_enabled_reactions = + "laughing|open_mouth|cry|angry|thumbsup|hugs" + user_2.user_stat.update!(post_count: 1) + end + + describe "#toggle" do + let(:payload_with_user) { [{ "id" => "hugs", "type" => "emoji", "count" => 1 }] } + let(:api_key) { Fabricate(:api_key, user: admin, created_by: admin) } + + it "toggles reaction" do + sign_in(user_1) + expected_payload = [{ "id" => "hugs", "type" => "emoji", "count" => 1 }] + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/hugs/toggle.json" + end.to change { DiscourseReactions::Reaction.count }.by(1).and change { + DiscourseReactions::ReactionUser.count + }.by(1) + + expect(response.status).to eq(200) + expect(response.parsed_body["reactions"]).to eq(expected_payload) + + reaction = DiscourseReactions::Reaction.last + expect(reaction.reaction_value).to eq("hugs") + expect(reaction.reaction_users_count).to eq(1) + + sign_in(user_2) + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/hugs/toggle.json" + reaction = DiscourseReactions::Reaction.last + expect(reaction.reaction_value).to eq("hugs") + expect(reaction.reaction_users_count).to eq(2) + + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/hugs/toggle.json" + end.to not_change { DiscourseReactions::Reaction.count }.and change { + DiscourseReactions::ReactionUser.count + }.by(-1) + + expect(response.status).to eq(200) + expect(response.parsed_body["reactions"]).to eq(expected_payload) + + sign_in(user_1) + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/hugs/toggle.json" + end.to change { DiscourseReactions::Reaction.count }.by(-1).and change { + DiscourseReactions::ReactionUser.count + }.by(-1) + + expect(response.status).to eq(200) + expect(response.parsed_body["reactions"]).to eq([]) + end + + it "publishes MessageBus messages" do + sign_in(user_1) + + messages = + MessageBus.track_publish("/topic/#{post_1.topic.id}") do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/cry/toggle.json" + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/cry/toggle.json" + end + expect(messages.count).to eq(6) + expect(messages.map(&:data).map { |m| m[:type] }.uniq).to match_array( + %i[acted liked unliked stats], + ) + + messages = + MessageBus.track_publish("/topic/#{post_1.topic.id}/reactions") do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/cry/toggle.json" + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/cry/toggle.json" + end + expect(messages.count).to eq(2) + expect(messages.map(&:channel).uniq.first).to eq("/topic/#{post_1.topic.id}/reactions") + expect(messages[0].data[:reactions]).to contain_exactly("cry") + expect(messages[1].data[:reactions]).to contain_exactly("cry") + + messages = + MessageBus.track_publish("/topic/#{post_1.topic.id}/reactions") do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/cry/toggle.json" + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/angry/toggle.json" + end + expect(messages.count).to eq(2) + expect(messages.map(&:channel).uniq.first).to eq("/topic/#{post_1.topic.id}/reactions") + expect(messages[0].data[:reactions]).to contain_exactly("cry") + expect(messages[1].data[:reactions]).to contain_exactly("cry", "angry") + end + + it "publishes MessageBus messages securely" do + sign_in(user_1) + messages = + MessageBus.track_publish("/topic/#{private_post.topic.id}/reactions") do + put "/discourse-reactions/posts/#{private_post.id}/custom-reactions/cry/toggle.json", + headers: { + "HTTP_API_KEY" => api_key.key, + "HTTP_API_USERNAME" => api_key.user.username, + } + end + user_1_messages = messages.find { |m| m.user_ids.include?(user_1.id) } + expect(messages.count).to eq(1) + expect(user_1_messages).to eq(nil) + end + + it "errors when reaction is invalid" do + sign_in(user_1) + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/invalid-reaction/toggle.json" + end.not_to change { DiscourseReactions::Reaction.count } + + expect(response.status).to eq(422) + end + end + + describe "#reactions_given" do + fab!(:private_topic) { Fabricate(:private_message_topic, user: user_2) } + fab!(:private_post) { Fabricate(:post, topic: private_topic) } + fab!(:secure_group) { Fabricate(:group) } + fab!(:secure_category) { Fabricate(:private_category, group: secure_group) } + fab!(:secure_topic) { Fabricate(:topic, category: secure_category) } + fab!(:secure_post) { Fabricate(:post, topic: secure_topic) } + fab!(:private_reaction) { Fabricate(:reaction, post: private_post, reaction_value: "hugs") } + fab!(:secure_reaction) { Fabricate(:reaction, post: secure_post, reaction_value: "hugs") } + fab!(:private_topic_reaction_user) do + Fabricate(:reaction_user, reaction: private_reaction, user: user_2, post: private_post) + end + fab!(:secure_topic_reaction_user) do + Fabricate(:reaction_user, reaction: secure_reaction, user: user_2, post: secure_post) + end + + it "returns reactions given by a user" do + sign_in(user_1) + + get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username } + expect(response.status).to eq(200) + + parsed = response.parsed_body + expect(parsed[0]["user"]["id"]).to eq(user_2.id) + expect(parsed[0]["post_id"]).to eq(post_2.id) + expect(parsed[0]["post"]["user"]["id"]).to eq(user_1.id) + expect(parsed[0]["reaction"]["id"]).to eq(laughing_reaction.id) + end + + it "does not return reactions for private messages" do + sign_in(user_1) + + get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username } + + parsed = response.parsed_body + expect(response.parsed_body.map { |reaction| reaction["post_id"] }).not_to include( + private_post.id, + ) + end + + it "returns reactions for private messages of current user" do + sign_in(user_2) + + get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username } + parsed = response.parsed_body + expect(response.parsed_body.map { |reaction| reaction["post_id"] }).to include( + private_post.id, + ) + end + + it "does not return reactions for secure categories" do + secure_group.add(user_2) + sign_in(user_1) + + get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username } + parsed = response.parsed_body + expect(response.parsed_body.map { |reaction| reaction["post_id"] }).not_to include( + secure_post.id, + ) + + secure_group.add(user_1) + get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username } + parsed = response.parsed_body + expect(response.parsed_body.map { |reaction| reaction["post_id"] }).to include(secure_post.id) + + sign_in(user_2) + + get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username } + parsed = response.parsed_body + expect(response.parsed_body.map { |reaction| reaction["post_id"] }).to include(secure_post.id) + end + + it "does not return reactions for whispers if the user is not in whispers_allowed_groups" do + sign_in(user_1) + + get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username } + + parsed = response.parsed_body + expect(response.parsed_body.map { |reaction| reaction["post_id"] }).not_to include( + whisper_post.id, + ) + + SiteSetting.whispers_allowed_groups = Group::AUTO_GROUPS[:trust_level_0].to_s + Group.refresh_automatic_groups! + + get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username } + + parsed = response.parsed_body + expect(response.parsed_body.map { |reaction| reaction["post_id"] }).to include( + whisper_post.id, + ) + end + + describe "a post with one of your reactions has been deleted" do + fab!(:deleted_post) { Fabricate(:post) } + fab!(:kept_post) { Fabricate(:post) } + fab!(:user) + fab!(:reaction_on_deleted_post) do + Fabricate(:reaction, post: deleted_post, reaction_value: "laughing") + end + fab!(:reaction_on_kept_post) do + Fabricate(:reaction, post: kept_post, reaction_value: "laughing") + end + fab!(:reaction_user_on_deleted_post) do + Fabricate( + :reaction_user, + reaction: reaction_on_deleted_post, + user: user, + post: deleted_post, + ) + end + fab!(:reaction_user_on_kept_post) do + Fabricate(:reaction_user, reaction: reaction_on_kept_post, user: user, post: kept_post) + end + + it "doesn’t return the deleted post/reaction" do + sign_in(user) + + get "/discourse-reactions/posts/reactions.json", params: { username: user.username } + parsed = response.parsed_body + expect(parsed.length).to eq(2) + + PostDestroyer.new(Discourse.system_user, deleted_post).destroy + + get "/discourse-reactions/posts/reactions.json", params: { username: user.username } + parsed = response.parsed_body + + expect(parsed.length).to eq(1) + expect(parsed[0]["post_id"]).to eq(kept_post.id) + end + end + + context "when op containing reactions is destroyed" do + fab!(:topic) { create_topic } + fab!(:op) { Fabricate(:post, topic: topic) } + + it "doesn’t return the reactions from deleted topic" do + deleted_topic_id = topic.id + sign_in(user_1) + put "/discourse-reactions/posts/#{op.id}/custom-reactions/hugs/toggle.json" + get "/discourse-reactions/posts/reactions.json", params: { username: user_1.username } + + expect(response.parsed_body.length).to eq(2) + + PostDestroyer.new(Discourse.system_user, op).destroy + get "/discourse-reactions/posts/reactions.json", params: { username: user_1.username } + + parsed = response.parsed_body + expect(parsed.length).to eq(1) + expect(parsed[0]["topic_id"]).to_not eq(deleted_topic_id) + end + end + end + + describe "#reactions_received" do + it "returns reactions received by a user when current user is admin" do + sign_in(admin) + + get "/discourse-reactions/posts/reactions-received.json", + params: { + username: user_1.username, + } + parsed = response.parsed_body + + expect(parsed[0]["user"]["id"]).to eq(user_3.id) + expect(parsed[0]["post_id"]).to eq(post_2.id) + expect(parsed[0]["post"]["user"]["id"]).to eq(user_1.id) + expect(parsed[0]["reaction"]["id"]).to eq(open_mouth_reaction.id) + end + + it "does not return reactions received by a user when current user is not an admin" do + sign_in(user_1) + + get "/discourse-reactions/posts/reactions-received.json", + params: { + username: user_2.username, + } + + expect(response.status).to eq(403) + end + + it "filters by acting username" do + sign_in(user_1) + + get "/discourse-reactions/posts/reactions-received.json", + params: { + username: user_1.username, + acting_username: user_4.username, + } + parsed = response.parsed_body + + expect(parsed.size).to eq(1) + expect(parsed[0]["user"]["id"]).to eq(user_4.id) + expect(parsed[0]["post_id"]).to eq(post_2.id) + expect(parsed[0]["post"]["user"]["id"]).to eq(user_1.id) + expect(parsed[0]["reaction"]["id"]).to eq(hugs_reaction.id) + end + + it "include likes" do + sign_in(user_1) + + get "/discourse-reactions/posts/reactions-received.json", + params: { + username: user_1.username, + include_likes: true, + acting_username: user_5.username, + } + + parsed = response.parsed_body + + expect(parsed.size).to eq(1) + expect(parsed[0]["user"]["id"]).to eq(user_5.id) + expect(parsed[0]["post_id"]).to eq(post_2.id) + expect(parsed[0]["post"]["user"]["id"]).to eq(user_1.id) + expect(parsed[0]["reaction"]["id"]).to eq(like.id) + end + + it "does not include reactions which also count as a like when include_likes is true" do + sign_in(user_1) + other_post = Fabricate(:post, user: user_1) + laugh = Fabricate(:reaction_user, reaction: laughing_reaction, user: user_5, post: other_post) + + get "/discourse-reactions/posts/reactions-received.json", + params: { + username: user_1.username, + include_likes: true, + acting_username: user_5.username, + } + + parsed = response.parsed_body + expect(parsed.size).to eq(2) + + expect(parsed[0]["user"]["id"]).to eq(user_5.id) + expect(parsed[0]["post_id"]).to eq(other_post.id) + expect(parsed[0]["post"]["user"]["id"]).to eq(user_1.id) + expect(parsed[0]["reaction"]["id"]).to eq(laugh.reaction.id) + + expect(parsed[1]["user"]["id"]).to eq(user_5.id) + expect(parsed[1]["post_id"]).to eq(post_2.id) + expect(parsed[1]["post"]["user"]["id"]).to eq(user_1.id) + expect(parsed[1]["reaction"]["id"]).to eq(like.id) + end + + it "also filter likes by id when including likes" do + latest_like = + Fabricate( + :post_action, + post: post_1, + user: user_5, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + sign_in(user_1) + + get "/discourse-reactions/posts/reactions-received.json", + params: { + username: user_1.username, + include_likes: true, + acting_username: user_5.username, + before_like_id: latest_like.id, + } + + parsed = response.parsed_body + + expect(parsed.size).to eq(1) + expect(parsed[0]["user"]["id"]).to eq(user_5.id) + expect(parsed[0]["post_id"]).to eq(post_2.id) + expect(parsed[0]["post"]["user"]["id"]).to eq(user_1.id) + expect(parsed[0]["reaction"]["id"]).to eq(like.id) + end + + it "filters likes by username" do + latest_like = + Fabricate( + :post_action, + post: post_1, + user: user_4, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + sign_in(user_1) + + get "/discourse-reactions/posts/reactions-received.json", + params: { + username: user_1.username, + include_likes: true, + acting_username: user_5.username, + } + + parsed = response.parsed_body + + expect(parsed.size).to eq(1) + expect(parsed[0]["user"]["id"]).to eq(user_5.id) + expect(parsed[0]["post_id"]).to eq(post_2.id) + expect(parsed[0]["post"]["user"]["id"]).to eq(user_1.id) + expect(parsed[0]["reaction"]["id"]).to eq(like.id) + end + end + + describe "#post_reactions_users" do + it "return reaction_users of post when theres no parameters" do + get "/discourse-reactions/posts/#{post_2.id}/reactions-users.json" + parsed = response.parsed_body + + expect(response.status).to eq(200) + expect(parsed["reaction_users"][0]["users"][0]["username"]).to eq(user_5.username) + expect(parsed["reaction_users"][0]["users"][0]["name"]).to eq(user_5.name) + expect(parsed["reaction_users"][0]["users"][0]["avatar_template"]).to eq( + user_5.avatar_template, + ) + end + + it "return reaction_users of reaction when there are parameters" do + get "/discourse-reactions/posts/#{post_2.id}/reactions-users.json?reaction_value=#{laughing_reaction.reaction_value}" + parsed = response.parsed_body + + expect(response.status).to eq(200) + expect(parsed["reaction_users"][0]["users"][0]["username"]).to eq(user_1.username) + expect(parsed["reaction_users"][0]["users"][0]["name"]).to eq(user_1.name) + expect(parsed["reaction_users"][0]["users"][0]["avatar_template"]).to eq( + user_1.avatar_template, + ) + end + + it "gives 404 ERROR when the post_id OR reaction_value is invalid" do + get "/discourse-reactions/posts/1000000/reactions-users.json" + expect(response.status).to eq(404) + + get "/discourse-reactions/posts/1000000/reactions-users.json?reaction_value=test" + expect(response.status).to eq(404) + end + + it "merges matching custom reaction into likes" do + get "/discourse-reactions/posts/#{post_2.id}/reactions-users.json?reaction_value=#{DiscourseReactions::Reaction.main_reaction_id}" + parsed = response.parsed_body + like_count = parsed["reaction_users"][0]["count"].to_i + expect(like_count).to eq(1) + + get "/discourse-reactions/posts/#{post_2.id}/reactions-users.json?reaction_value=laughing" + parsed = response.parsed_body + reaction_count = parsed["reaction_users"][0]["count"].to_i + expect(reaction_count).to eq(2) + + SiteSetting.discourse_reactions_reaction_for_like = "laughing" + + get "/discourse-reactions/posts/#{post_2.id}/reactions-users.json?reaction_value=#{DiscourseReactions::Reaction.main_reaction_id}" + parsed = response.parsed_body + expect(parsed["reaction_users"][0]["count"]).to eq(like_count + reaction_count) + end + + it "does not show reaction_users on PMs without permission" do + get "/discourse-reactions/posts/#{private_post.id}/reactions-users.json" + expect(response.status).to eq(403) + end + + it "shows reaction_users on PMs with permission" do + sign_in(user_2) + get "/discourse-reactions/posts/#{private_post.id}/reactions-users.json" + expect(response.status).to eq(200) + end + + it "does not double up reactions which also count as likes if the reaction is no longer enabled" do + post_for_enabled_reactions = Fabricate(:post, user: user_2) + new_reaction_1 = + Fabricate(:reaction, post: post_for_enabled_reactions, reaction_value: "laughing") + new_reaction_user_1 = + Fabricate( + :reaction_user, + user: user_5, + reaction: new_reaction_1, + post: post_for_enabled_reactions, + ) + new_like_1 = + Fabricate( + :post_action, + post: post_for_enabled_reactions, + user: user_4, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + + get "/discourse-reactions/posts/#{post_for_enabled_reactions.id}/reactions-users.json" + parsed = response.parsed_body + + expect(response.status).to eq(200) + expect( + parsed["reaction_users"].find { |reaction| reaction["id"] == "laughing" }["count"], + ).to eq(1) + expect(parsed["reaction_users"].find { |reaction| reaction["id"] == "heart" }["count"]).to eq( + 1, + ) + + SiteSetting.discourse_reactions_enabled_reactions = "+1" + + get "/discourse-reactions/posts/#{post_for_enabled_reactions.id}/reactions-users.json" + parsed = response.parsed_body + + expect(response.status).to eq(200) + expect( + parsed["reaction_users"].find { |reaction| reaction["id"] == "laughing" }["count"], + ).to eq(1) + expect(parsed["reaction_users"].find { |reaction| reaction["id"] == "heart" }["count"]).to eq( + 1, + ) + end + end + + describe "positive notifications" do + before { PostActionNotifier.enable } + + it "creates notification when first like" do + sign_in(user_1) + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/heart/toggle.json" + end.to change { Notification.count }.by(1).and change { PostAction.count }.by(1) + + expect(PostAction.last.post_action_type_id).to eq(PostActionType::LIKE_POST_ACTION_ID) + + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/heart/toggle.json" + end.to change { Notification.count }.by(-1).and change { PostAction.count }.by(-1) + end + end + + describe "reaction notifications" do + it "calls ReactinNotification service" do + sign_in(user_1) + DiscourseReactions::ReactionNotification.any_instance.expects(:create).once + DiscourseReactions::ReactionNotification.any_instance.expects(:delete).once + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/cry/toggle.json" + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/cry/toggle.json" + end + end + + it "allows to delete reaction only in undo action window frame" do + SiteSetting.post_undo_action_window_mins = 10 + sign_in(user_1) + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/hugs/toggle.json" + end.to change { DiscourseReactions::Reaction.count }.by(1).and change { + DiscourseReactions::ReactionUser.count + }.by(1) + + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/hugs/toggle.json" + end.to change { DiscourseReactions::Reaction.count }.by(-1).and change { + DiscourseReactions::ReactionUser.count + }.by(-1) + + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/hugs/toggle.json" + end.to change { DiscourseReactions::Reaction.count }.by(1).and change { + DiscourseReactions::ReactionUser.count + }.by(1) + + freeze_time(Time.zone.now + 11.minutes) + expect do + put "/discourse-reactions/posts/#{post_1.id}/custom-reactions/hugs/toggle.json" + end.to not_change { DiscourseReactions::Reaction.count }.and not_change { + DiscourseReactions::ReactionUser.count + } + + expect(response.status).to eq(403) + end +end diff --git a/plugins/discourse-reactions/spec/requests/post_action_users_controller_spec.rb b/plugins/discourse-reactions/spec/requests/post_action_users_controller_spec.rb new file mode 100644 index 00000000000..9efc6240a24 --- /dev/null +++ b/plugins/discourse-reactions/spec/requests/post_action_users_controller_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe PostActionUsersController do + before { SiteSetting.discourse_reactions_enabled = true } + + describe "post_action_users_list modifier for PostActionUsersController" do + fab!(:current_user) { Fabricate(:user) } + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:post) + + before do + DiscourseReactions::ReactionManager.new( + reaction_value: "clap", + user: user_1, + post: post, + ).toggle! + + DiscourseReactions::ReactionManager.new( + reaction_value: DiscourseReactions::Reaction.main_reaction_id, + user: user_2, + post: post, + ).toggle! + end + + it "excludes users for a post who have a ReactionUser as well as a PostAction like" do + sign_in(current_user) + get "/post_action_users.json", + params: { + id: post.id, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + } + expect(response.status).to eq(200) + expect(response.parsed_body["post_action_users"].map { |u| u["id"] }).to match_array( + [user_2.id], + ) + end + end +end diff --git a/plugins/discourse-reactions/spec/requests/topics_controller_spec.rb b/plugins/discourse-reactions/spec/requests/topics_controller_spec.rb new file mode 100644 index 00000000000..a9c2b41826f --- /dev/null +++ b/plugins/discourse-reactions/spec/requests/topics_controller_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe TopicsController do + fab!(:post) + + fab!(:laughing_reaction) { Fabricate(:reaction, post: post, reaction_value: "laughing") } + fab!(:open_mouth_reaction) { Fabricate(:reaction, post: post, reaction_value: "open_mouth") } + fab!(:hugs_reaction) { Fabricate(:reaction, post: post, reaction_value: "hugs") } + + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user) } + fab!(:user_4) { Fabricate(:user) } + + before do + SiteSetting.discourse_reactions_enabled = true + SiteSetting.discourse_reactions_enabled_reactions = + "laughing|open_mouth|cry|angry|thumbsup|hugs" + end + + describe "#show" do + it "does not generate N+1 queries" do + sign_in(user_1) + + queries = track_sql_queries { get "/t/#{post.topic_id}.json" } + count = queries.filter { |q| q.include?("reactions") }.size + + Fabricate(:reaction_user, reaction: laughing_reaction, user: user_1, post: post) + Fabricate(:reaction_user, reaction: laughing_reaction, user: user_2, post: post) + + queries = track_sql_queries { get "/t/#{post.topic_id}.json" } + expect(queries.filter { |q| q.include?("reactions") }.size).to eq(count) + + Fabricate(:reaction_user, reaction: hugs_reaction, user: user_3, post: post) + Fabricate(:reaction_user, reaction: open_mouth_reaction, user: user_4, post: post) + + queries = track_sql_queries { get "/t/#{post.topic_id}.json" } + expect(queries.filter { |q| q.include?("reactions") }.size).to eq(count) + end + end +end diff --git a/plugins/discourse-reactions/spec/serializers/post_serializer_spec.rb b/plugins/discourse-reactions/spec/serializers/post_serializer_spec.rb new file mode 100644 index 00000000000..3f453f23d6c --- /dev/null +++ b/plugins/discourse-reactions/spec/serializers/post_serializer_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "rails_helper" +require_relative "../fabricators/reaction_fabricator.rb" +require_relative "../fabricators/reaction_user_fabricator.rb" + +describe PostSerializer do + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user) } + fab!(:user_4) { Fabricate(:user) } + fab!(:post_1) { Fabricate(:post, user: user_1) } + let(:reaction_otter) { Fabricate(:reaction, reaction_value: "otter", post: post_1) } + let(:reaction_plus_1) { Fabricate(:reaction, reaction_value: "+1", post: post_1) } + let(:reaction_user_1) do + Fabricate(:reaction_user, reaction: reaction_otter, user: user_1, post: post_1) + end + let(:reaction_user_2) do + Fabricate(:reaction_user, reaction: reaction_otter, user: user_2, post: post_1) + end + let(:reaction_user_3) do + Fabricate( + :reaction_user, + reaction: reaction_plus_1, + user: user_3, + post: post_1, + created_at: 20.minutes.ago, + ) + end + fab!(:like) do + Fabricate( + :post_action, + post: post_1, + user: user_4, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + end + + before do + SiteSetting.discourse_reactions_enabled = true + SiteSetting.post_undo_action_window_mins = 10 + SiteSetting.discourse_reactions_enabled_reactions = "otter|+1" + SiteSetting.discourse_reactions_like_icon = "heart" + + reaction_user_1 && reaction_user_2 && reaction_user_3 && like + + post_1.post_actions_with_reaction_users = + DiscourseReactions::TopicViewSerializerExtension.load_post_action_reaction_users_for_posts( + [post_1.id], + )[ + post_1.id + ] + end + + it "renders custom reactions which should be sorted by count" do + json = PostSerializer.new(post_1, scope: Guardian.new(user_1), root: false).as_json + + expect(json[:reactions]).to eq( + [ + { id: "otter", type: :emoji, count: 2 }, + { id: "+1", type: :emoji, count: 1 }, + { id: "heart", type: :emoji, count: 1 }, + ], + ) + + expect(json[:current_user_reaction]).to eq({ type: :emoji, id: "otter", can_undo: true }) + + json = PostSerializer.new(post_1, scope: Guardian.new(user_2), root: false).as_json + + expect(json[:reaction_users_count]).to eq(4) + end + + it "renders custom reactions sorted alphabetically if count is equal" do + json = PostSerializer.new(post_1, scope: Guardian.new(user_1), root: false).as_json + + expect(json[:reactions]).to eq( + [ + { id: "otter", type: :emoji, count: 2 }, + { id: "+1", type: :emoji, count: 1 }, + { id: "heart", type: :emoji, count: 1 }, + ], + ) + end + + it "does not double up reactions which also count as likes if the reaction is no longer enabled" do + SiteSetting.discourse_reactions_enabled_reactions = "+1" + json = PostSerializer.new(post_1, scope: Guardian.new(user_1), root: false).as_json + + expect(json[:reactions]).to eq( + [ + { id: "otter", type: :emoji, count: 2 }, + { id: "+1", type: :emoji, count: 1 }, + { id: "heart", type: :emoji, count: 1 }, + ], + ) + end + + describe "custom emojis" do + fab!(:custom_emoji) do + CustomEmoji.create!(upload: Fabricate(:image_upload), name: "some_custom_emoji") + end + + fab!(:custom_emoji_reaction) do + Fabricate(:reaction, reaction_value: custom_emoji.name, post: post_1) + end + + fab!(:user_5) { Fabricate(:user) } + + fab!(:custom_reaction_user) do + Fabricate(:reaction_user, reaction: custom_emoji_reaction, user: user_5, post: post_1) + end + + before do + SiteSetting.discourse_reactions_enabled_reactions += "|#{custom_emoji.name}" + Emoji.clear_cache + end + + it "renders the right custom reactions including custom emoji" do + json = PostSerializer.new(post_1, scope: Guardian.new(user_5), root: false).as_json + + expect(json[:reactions]).to eq( + [ + { id: "otter", type: :emoji, count: 2 }, + { id: "+1", type: :emoji, count: 1 }, + { id: "heart", type: :emoji, count: 1 }, + { id: "some_custom_emoji", type: :emoji, count: 1 }, + ], + ) + end + + it "renders custom reactions correctly when custom emoji is destroyed" do + custom_emoji.destroy! + Emoji.clear_cache + + json = PostSerializer.new(post_1.reload, scope: Guardian.new(user_5), root: false).as_json + + expect(json[:reactions]).to eq( + [ + { id: "otter", type: :emoji, count: 2 }, + { id: "+1", type: :emoji, count: 1 }, + { id: "heart", type: :emoji, count: 1 }, + ], + ) + end + end + + context "when disabled" do + it "is not extending post serializer when plugin is disabled" do + SiteSetting.discourse_reactions_enabled = false + json = PostSerializer.new(post_1, scope: Guardian.new(user_1), root: false).as_json + expect(json[:reactions]).to be nil + end + end + + describe "changing discourse_reactions_like_icon" do + before { SiteSetting.discourse_reactions_reaction_for_like = "otter" } + + it "merges the newly matching custom reaction into likes" do + json = PostSerializer.new(post_1, scope: Guardian.new(user_1), root: false).as_json + + expect(json[:reactions]).to eq( + [{ id: "otter", type: :emoji, count: 3 }, { id: "+1", type: :emoji, count: 1 }], + ) + end + end +end diff --git a/plugins/discourse-reactions/spec/serializers/topic_view_serializer_spec.rb b/plugins/discourse-reactions/spec/serializers/topic_view_serializer_spec.rb new file mode 100644 index 00000000000..834bbad07ed --- /dev/null +++ b/plugins/discourse-reactions/spec/serializers/topic_view_serializer_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "rails_helper" +require_relative "../fabricators/reaction_fabricator.rb" +require_relative "../fabricators/reaction_user_fabricator.rb" + +describe TopicViewSerializer do + before { SiteSetting.discourse_reactions_enabled = true } + + context "with reactions and shadow like" do + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:post_1) { Fabricate(:post, user: user_1) } + fab!(:post_2) { Fabricate(:post, user: user_1, topic: post_1.topic) } + fab!(:otter) { Fabricate(:reaction, post: post_1, reaction_value: "otter") } + fab!(:reaction_user1) { Fabricate(:reaction_user, reaction: otter, user: user_1) } + fab!(:reaction_user2) { Fabricate(:reaction_user, reaction: otter, user: user_2) } + fab!(:like_1) do + Fabricate( + :post_action, + post: post_1, + user: user_1, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + end + fab!(:like_2) do + Fabricate( + :post_action, + post: post_1, + user: user_2, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + end + let(:topic) { post_1.topic } + let(:topic_view) { TopicView.new(topic) } + + it "shows valid reactions and user reactions" do + SiteSetting.discourse_reactions_like_icon = "heart" + SiteSetting.discourse_reactions_enabled_reactions = + "laughing|heart|open_mouth|cry|angry|thumbsup|thumbsdown" + json = TopicViewSerializer.new(topic_view, scope: Guardian.new(user_1), root: false).as_json + expect(json[:valid_reactions]).to eq( + %w[laughing heart open_mouth cry angry thumbsup thumbsdown].to_set, + ) + expect(json[:post_stream][:posts][0][:reactions]).to eq( + [{ id: "otter", type: :emoji, count: 2 }], + ) + + expect(json[:post_stream][:posts][0][:reaction_users_count]).to eq(2) + end + + it "doesnt count deleted likes" do + SiteSetting.discourse_reactions_like_icon = "heart" + + json = TopicViewSerializer.new(topic_view, scope: Guardian.new(user_2), root: false).as_json + + expect(json[:post_stream][:posts][1][:reaction_users_count]).to eq(0) + + DiscourseReactions::ReactionManager.new( + reaction_value: "heart", + user: user_2, + post: post_2, + ).toggle! + json = + TopicViewSerializer.new( + TopicView.new(topic), + scope: Guardian.new(user_2), + root: false, + ).as_json + + expect(json[:post_stream][:posts][1][:reaction_users_count]).to eq(1) + + DiscourseReactions::ReactionManager.new( + reaction_value: "heart", + user: user_2, + post: post_2, + ).toggle! + json = + TopicViewSerializer.new( + TopicView.new(topic), + scope: Guardian.new(user_2), + root: false, + ).as_json + + expect(json[:post_stream][:posts][1][:reaction_users_count]).to eq(0) + end + end + + describe "only shadow like" do + fab!(:user_1) { Fabricate(:user) } + fab!(:post_1) { Fabricate(:post, user: user_1) } + fab!(:like_1) do + Fabricate( + :post_action, + post: post_1, + user: user_1, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + end + let(:topic) { post_1.topic } + let(:topic_view) { TopicView.new(topic) } + + it "shows valid reactions and user reactions" do + SiteSetting.discourse_reactions_like_icon = "heart" + json = TopicViewSerializer.new(topic_view, scope: Guardian.new(user_1), root: false).as_json + expect(json[:post_stream][:posts][0][:reactions]).to eq( + [{ id: "heart", type: :emoji, count: 1 }], + ) + + expect(json[:post_stream][:posts][0][:reaction_users_count]).to eq(1) + end + end +end diff --git a/plugins/discourse-reactions/spec/services/badge_granter_spec.rb b/plugins/discourse-reactions/spec/services/badge_granter_spec.rb new file mode 100644 index 00000000000..c317c1c5d9a --- /dev/null +++ b/plugins/discourse-reactions/spec/services/badge_granter_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" +require_relative "../fabricators/reaction_fabricator.rb" +require_relative "../fabricators/reaction_user_fabricator.rb" + +describe BadgeGranter do + fab!(:user) + fab!(:post) + fab!(:reaction) { Fabricate(:reaction, post: post) } + fab!(:reaction_user) { Fabricate(:reaction_user, reaction: reaction, user: user, post: post) } + let(:badge) { Badge.find_by(name: "First Reaction") } + + before do + SiteSetting.discourse_reactions_enabled = true + BadgeGranter.enable_queue + end + + after do + BadgeGranter.disable_queue + BadgeGranter.clear_queue! + end + + describe "First Reaction" do + it "badge is available" do + expect(badge).not_to eq(nil) + end + + it "badge query is not broken" do + backfill = BadgeGranter.backfill(badge) + + expect(backfill).to eq(true) + end + + it "can backfill the badge" do + UserBadge.destroy_all + BadgeGranter.backfill(badge) + + b = UserBadge.find_by(user_id: user.id) + + expect(b.post_id).to eq(post.id) + expect(b.badge_id).to eq(badge.id) + end + end +end diff --git a/plugins/discourse-reactions/spec/services/reaction_like_synchronizer_spec.rb b/plugins/discourse-reactions/spec/services/reaction_like_synchronizer_spec.rb new file mode 100644 index 00000000000..1eb38b4953c --- /dev/null +++ b/plugins/discourse-reactions/spec/services/reaction_like_synchronizer_spec.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseReactions::ReactionLikeSynchronizer do + let!(:user) { Fabricate(:user) } + let!(:post) { Fabricate(:post, like_count: 1) } + let!(:post_2) { Fabricate(:post, like_count: 0) } + + before do + SiteSetting.discourse_reactions_like_sync_enabled = true + SiteSetting.discourse_reactions_enabled_reactions += "heart|clap|+1|-1" + SiteSetting.discourse_reactions_excluded_from_like = "clap|-1" + + UserActionManager.enable + end + + let!(:topic_user) { Fabricate(:topic_user, user: user, topic: post.topic) } + let!(:topic_user_2) { Fabricate(:topic_user, user: user, topic: post_2.topic) } + + # This and reaction_user_2 use the ReactionManager so the proper PostActionCreator + # records are created, rather than building this all manually. + let!(:reaction_user) do + DiscourseReactions::ReactionManager.new(reaction_value: "+1", user: user, post: post).toggle! + @reaction_plus_one = DiscourseReactions::Reaction.find_by(reaction_value: "+1", post: post) + DiscourseReactions::ReactionUser.find_by(user: user, post: post, reaction: @reaction_plus_one) + end + + let!(:reaction_user_2) do + DiscourseReactions::ReactionManager.new( + reaction_value: "clap", + user: user, + post: post_2, + ).toggle! + @reaction_clap = DiscourseReactions::Reaction.find_by(reaction_value: "clap", post: post_2) + DiscourseReactions::ReactionUser.find_by(user: user, post: post_2, reaction: @reaction_clap) + end + + it "does nothing if discourse_reactions_like_sync_enabled is false" do + DB.expects(:exec).never + SiteSetting.discourse_reactions_like_sync_enabled = false + expect { described_class.sync! }.not_to change { PostAction.count } + end + + describe "when reactions are added to the exclusion list" do + before do + SiteSetting.discourse_reactions_excluded_from_like += "|+1" # +1 added + end + + it "trashes PostAction records" do + post_action_id = reaction_user.post_action_like.id + expect(reaction_user.post_action_like).to be_present + expect { described_class.sync! }.to change { PostAction.count }.by(-1) + expect(reaction_user.reload.post_action_like).to be_nil + expect(PostAction.with_deleted.find_by(id: post_action_id).deleted_at).to be_present + end + + it "removes UserAction records for LIKED and WAS_LIKED" do + expect { described_class.sync! }.to change { UserAction.count }.by(-2) + end + + it "updates the like_count on the associated Post records" do + expect(post.reload.like_count).to eq(1) + expect(post_2.reload.like_count).to eq(0) + described_class.sync! + expect(post.reload.like_count).to eq(0) + expect(post_2.reload.like_count).to eq(0) + end + + it "updates the like_count on the associated Topic records" do + expect(post.topic.reload.like_count).to eq(1) + expect(post_2.topic.reload.like_count).to eq(0) + described_class.sync! + expect(post.topic.reload.like_count).to eq(0) + expect(post_2.topic.reload.like_count).to eq(0) + end + + it "updates the liked column on TopicUser for associated topic and users" do + expect(post.topic.topic_users.find_by(user: user).liked).to eq(true) + expect(post_2.topic.topic_users.find_by(user: user).liked).to eq(false) + described_class.sync! + expect(post.topic.topic_users.find_by(user: user).liked).to eq(false) + expect(post_2.topic.topic_users.find_by(user: user).liked).to eq(false) + end + + it "updates the UserStat likes_given and likes_received columns" do + expect(user.user_stat.reload.likes_given).to eq(1) + expect(post.user.user_stat.reload.likes_received).to eq(1) + described_class.sync! + expect(user.user_stat.reload.likes_given).to eq(0) + expect(post.user.user_stat.reload.likes_received).to eq(0) + end + + it "updates/recalculates the GivenDailyLike table likes_given on all given_date days and deletes records where likes_given would be 0" do + expect( + GivenDailyLike.exists?(user: user, given_date: Time.now.to_date, likes_given: 1), + ).to eq(true) + described_class.sync! + expect(GivenDailyLike.exists?(user: user, given_date: Time.now.to_date)).to eq(false) + end + end + + describe "when reactions are removed from the exclusion list" do + it "creates PostAction records" do + SiteSetting.discourse_reactions_excluded_from_like = "-1" # clap removed + expect(reaction_user_2.post_action_like).to be_nil + expect { described_class.sync! }.to change { PostAction.count }.by(1) + expect(reaction_user_2.reload.post_action_like).to be_present + end + + it "updates existing trashed PostUpdate records to recover them" do + trashed_post_action = + Fabricate( + :post_action, + post: reaction_user_2.post, + user: reaction_user_2.user, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + trashed_post_action.trash!(Fabricate(:user)) + SiteSetting.discourse_reactions_excluded_from_like = "-1" # clap removed + expect { described_class.sync! }.to change { PostAction.count }.by(1) + expect(trashed_post_action.reload.trashed?).to eq(false) + end + + it "creates UserAction records for LIKED and WAS_LIKED" do + SiteSetting.discourse_reactions_excluded_from_like = "-1" # clap removed + expect { described_class.sync! }.to change { UserAction.count }.by(2) + expect( + UserAction.exists?( + action_type: UserAction::LIKE, + user_id: reaction_user_2.post_action_like.user_id, + acting_user_id: reaction_user_2.post_action_like.user_id, + target_post_id: reaction_user_2.post_action_like.post_id, + target_topic_id: reaction_user_2.post.topic_id, + ), + ).to eq(true) + expect( + UserAction.exists?( + action_type: UserAction::WAS_LIKED, + user_id: reaction_user_2.post.user_id, + acting_user_id: reaction_user_2.post_action_like.user_id, + target_post_id: reaction_user_2.post_action_like.post_id, + target_topic_id: reaction_user_2.post.topic_id, + ), + ).to eq(true) + end + + it "skips UserAction records where the post has a null user" do + reaction_user_2.post.update_columns(user_id: nil) + SiteSetting.discourse_reactions_excluded_from_like = "-1" # clap removed + expect { described_class.sync! }.not_to change { UserAction.count } + end + + it "if no reactions are excluded from like it adds post actions for ones previously excluded" do + SiteSetting.discourse_reactions_excluded_from_like = "" + expect(reaction_user_2.post_action_like).to be_nil + expect { described_class.sync! }.to change { PostAction.count }.by(1) + expect(reaction_user_2.reload.post_action_like).to be_present + end + + it "updates the like_count on the associated Post records" do + expect(post.reload.like_count).to eq(1) + expect(post_2.reload.like_count).to eq(0) + + SiteSetting.discourse_reactions_excluded_from_like = "-1" # clap removed + described_class.sync! + expect(post.reload.like_count).to eq(1) + expect(post_2.reload.like_count).to eq(1) + end + + it "updates the like_count on the associated Topic records" do + expect(post.topic.reload.like_count).to eq(1) + expect(post_2.topic.reload.like_count).to eq(0) + + SiteSetting.discourse_reactions_excluded_from_like = "-1" # clap removed + described_class.sync! + expect(post.topic.reload.like_count).to eq(1) + expect(post_2.topic.reload.like_count).to eq(1) + end + + it "updates the liked column on TopicUser for associated topic and users" do + expect(post.topic.topic_users.find_by(user: user).liked).to eq(true) + expect(post_2.topic.topic_users.find_by(user: user).liked).to eq(false) + + SiteSetting.discourse_reactions_excluded_from_like = "-1" # clap removed + described_class.sync! + expect(post.topic.topic_users.find_by(user: user).liked).to eq(true) + expect(post_2.topic.topic_users.find_by(user: user).liked).to eq(true) + end + + it "updates the UserStat likes_given and likes_received columns" do + expect(user.user_stat.reload.likes_given).to eq(1) + expect(post.user.user_stat.reload.likes_received).to eq(1) + expect(post_2.user.user_stat.reload.likes_received).to eq(0) + + SiteSetting.discourse_reactions_excluded_from_like = "-1" # clap removed + described_class.sync! + expect(user.user_stat.reload.likes_given).to eq(2) + expect(post.user.user_stat.reload.likes_received).to eq(1) + expect(post_2.user.user_stat.reload.likes_received).to eq(1) + end + + it "updates/recalculates the GivenDailyLike table likes_given on all given_date days and deletes records where likes_given would be 0" do + SiteSetting.discourse_reactions_excluded_from_like = "-1" # clap removed + expect { described_class.sync! }.to change { + GivenDailyLike.find_by(user: user, given_date: Time.now.to_date).likes_given + }.to 2 + end + end +end diff --git a/plugins/discourse-reactions/spec/services/reaction_manager_spec.rb b/plugins/discourse-reactions/spec/services/reaction_manager_spec.rb new file mode 100644 index 00000000000..b39822a1d44 --- /dev/null +++ b/plugins/discourse-reactions/spec/services/reaction_manager_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseReactions::ReactionManager do + def reaction_manager(reaction_value) + described_class.new(reaction_value: reaction_value, user: user, post: post) + end + + fab!(:user) + fab!(:post) + fab!(:reaction_plus_one) { Fabricate(:reaction, reaction_value: "+1", post: post) } + fab!(:reaction_minus_one) { Fabricate(:reaction, reaction_value: "-1", post: post) } + fab!(:reaction_clap) { Fabricate(:reaction, reaction_value: "clap", post: post) } + fab!(:reaction_hugs) { Fabricate(:reaction, reaction_value: "hugs", post: post) } + + before { SiteSetting.discourse_reactions_reaction_for_like = "clap" } + + describe ".toggle!" do + context "when the user has not yet reacted to the post" do + context "when the new reaction matches discourse_reactions_reaction_for_like" do + it "does create a PostAction record" do + expect { reaction_manager("clap").toggle! }.to change { PostAction.count }.by(1) + end + + it "does not create a ReactionUser record" do + expect { reaction_manager("clap").toggle! }.not_to change { + DiscourseReactions::ReactionUser.count + } + end + + it "creates a reaction notification" do + expect { reaction_manager("clap").toggle! }.to change { Notification.count }.by(1) + end + end + + context "when the new reaction does not match discourse_reactions_reaction_for_like" do + it "does create a PostAction record" do + expect { reaction_manager("+1").toggle! }.to change { PostAction.count } + end + + it "does create a ReactionUser record" do + expect { reaction_manager("+1").toggle! }.to change { + DiscourseReactions::ReactionUser.count + } + end + + it "creates a reaction notification" do + expect { reaction_manager("+1").toggle! }.to change { Notification.count }.by(1) + end + + context "when the reaction is in discourse_reactions_excluded_from_like" do + before { SiteSetting.discourse_reactions_excluded_from_like = "+1" } + + it "does not create a PostAction record" do + expect { reaction_manager("+1").toggle! }.not_to change { PostAction.count } + end + end + end + end + + context "when the user already reacted to the Post" do + context "when the existing reaction was a ReactionUser" do + let!(:reaction_user) do + Fabricate(:reaction_user, user: user, post: post, reaction: reaction_plus_one) + end + + context "when the user has permission to delete the ReactionUser" do + it "removes the ReactionUser for the old +1 reaction" do + reaction_manager("-1").toggle! + expect(DiscourseReactions::ReactionUser.find_by(id: reaction_user.id)).to be_nil + end + + it "removes any PostAction that exists as well" do + expect { reaction_manager("-1").toggle! }.to change { PostAction.count }.by(-1) + end + + it "adds a new ReactionUser record for the new reaction -1 but not PostAction because of discourse_reactions_excluded_from_like" do + reaction_manager("-1").toggle! + expect( + DiscourseReactions::ReactionUser.find_by( + reaction: reaction_minus_one, + user: user, + post: post, + ), + ).to be_present + expect( + PostAction.find_by( + post: post, + user: user, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ), + ).to be_nil + end + + it "adds a new ReactionUser record and a PostAction record for reaction hugs" do + reaction_manager("hugs").toggle! + expect( + DiscourseReactions::ReactionUser.find_by( + reaction: reaction_hugs, + user: user, + post: post, + ), + ).to be_present + expect( + PostAction.find_by( + post: post, + user: user, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ), + ).to be_present + end + + it "deletes any notifications for the old Reaction and creates a notification for the new reaction" do + DiscourseReactions::ReactionNotification.new(reaction_plus_one, user).create + expect { reaction_manager("-1").toggle! }.not_to change { Notification.count } + expect( + Notification.where( + notification_type: Notification.types[:reaction], + topic_id: post.topic_id, + user_id: post.user_id, + post_number: post.post_number, + ).count, + ).to eq(1) + end + + it "removes the Reaction record attached to the post when no more users have reacted to it" do + expect { reaction_manager("-1").toggle! }.to change { + DiscourseReactions::Reaction.where(id: reaction_plus_one).count + }.by(-1) + end + + context "when the previous reaction is the same as the new one" do + before { reaction_user.update!(reaction: reaction_minus_one) } + + it "does not add a new ReactionUser record, just removes the old one" do + expect { reaction_manager("-1").toggle! }.to change { + DiscourseReactions::ReactionUser.count + }.by(-1).and change { PostAction.count }.by(-1) + end + end + end + + context "when the user does not have permission to delete the ReactionUser" do + before do + reaction_user.update!( + created_at: Time.zone.now - (SiteSetting.post_undo_action_window_mins + 1).minutes, + ) + end + + it "raises an error" do + expect { reaction_manager("-1").toggle! }.to raise_error(Discourse::InvalidAccess) + end + end + end + + context "when the existing reaction counted as a PostAction (Like) without a matching ReactionUser" do + let!(:post_action) do + Fabricate( + :post_action, + post: post, + user: user, + post_action_type_id: PostActionType::LIKE_POST_ACTION_ID, + ) + end + + context "when the user has permission to delete the PostAction" do + it "removes the PostAction" do + expect { reaction_manager("-1").toggle! }.to change { PostAction.count }.by(-1) + end + + it "removes the Reaction record attached to the post" do + expect { reaction_manager("-1").toggle! }.to change { + DiscourseReactions::Reaction.where(id: reaction_clap).count + }.by(-1) + end + + it "deletes any notifications for the old Reaction and creates a notification for the new reaction" do + DiscourseReactions::ReactionNotification.new(reaction_clap, user).create + expect { reaction_manager("-1").toggle! }.not_to change { Notification.count } + expect( + Notification.where( + notification_type: Notification.types[:reaction], + topic_id: post.topic_id, + user_id: post.user_id, + post_number: post.post_number, + ).count, + ).to eq(1) + end + end + + context "when the user does not have permission to delete the PostAction" do + before do + post_action.update!( + created_at: Time.zone.now - (SiteSetting.post_undo_action_window_mins + 1).minutes, + ) + end + + it "raises an error" do + expect { reaction_manager("-1").toggle! }.to raise_error(Discourse::InvalidAccess) + end + end + end + end + end +end diff --git a/plugins/discourse-reactions/spec/services/reaction_notification_spec.rb b/plugins/discourse-reactions/spec/services/reaction_notification_spec.rb new file mode 100644 index 00000000000..17a283c5eba --- /dev/null +++ b/plugins/discourse-reactions/spec/services/reaction_notification_spec.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require "rails_helper" +require_relative "../fabricators/reaction_fabricator.rb" +require_relative "../fabricators/reaction_user_fabricator.rb" + +describe DiscourseReactions::ReactionNotification do + before do + SiteSetting.discourse_reactions_enabled = true + PostActionNotifier.enable + end + + fab!(:post_1) { Fabricate(:post) } + fab!(:thumbsup) { Fabricate(:reaction, post: post_1, reaction_value: "thumbsup") } + fab!(:user_1) { Fabricate(:user, name: "Bruce Wayne Jr.") } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user, name: "Bruce Wayne Sr.") } + fab!(:reaction_user1) { Fabricate(:reaction_user, reaction: thumbsup, user: user_1) } + fab!(:like_reaction) { Fabricate(:reaction, post: post_1, reaction_value: "heart") } + + it "does not create notification when user is muted" do + MutedUser.create!(user_id: post_1.user.id, muted_user_id: user_1.id) + expect { described_class.new(thumbsup, user_1).create }.not_to change { Notification.count } + end + + it "does not create notification when topic is muted" do + TopicUser.create!( + topic: post_1.topic, + user: post_1.user, + notification_level: TopicUser.notification_levels[:muted], + ) + MutedUser.create!(user_id: post_1.user.id, muted_user_id: user_1.id) + described_class.new(thumbsup, user_1).create + expect { described_class.new(thumbsup, user_1).create }.not_to change { Notification.count } + end + + it "does not create notification when notification setting is never" do + post_1.user.user_option.update!( + like_notification_frequency: UserOption.like_notification_frequency_type[:never], + ) + MutedUser.create!(user_id: post_1.user.id, muted_user_id: user_1.id) + expect { described_class.new(thumbsup, user_1).create }.not_to change { Notification.count } + end + + it "correctly creates notification when notification setting is first time and daily" do + post_1.user.user_option.update!( + like_notification_frequency: + UserOption.like_notification_frequency_type[:first_time_and_daily], + ) + + expect { described_class.new(thumbsup, user_1).create }.to change { Notification.count }.by(1) + expect(Notification.last.user_id).to eq(post_1.user.id) + expect(Notification.last.notification_type).to eq(Notification.types[:reaction]) + expect(JSON.parse(Notification.last.data)["original_username"]).to eq(user_1.username) + + user_2 = Fabricate(:user) + Fabricate(:reaction_user, reaction: thumbsup, user: user_2) + expect { described_class.new(thumbsup, user_2).create }.not_to change { Notification.count } + + freeze_time(Time.zone.now + 1.day) + + cry = Fabricate(:reaction, post: post_1, reaction_value: "cry") + Fabricate(:reaction_user, reaction: cry, user: user_2) + expect { described_class.new(cry, user_2).create }.to change { Notification.count }.by(1) + end + + it "deletes notification when all reactions are removed" do + expect { described_class.new(thumbsup, user_1).create }.to change { Notification.count }.by(1) + + cry = Fabricate(:reaction, post: post_1, reaction_value: "cry") + Fabricate(:reaction_user, reaction: cry, user: user_1) + expect { described_class.new(cry, user_1).create }.not_to change { Notification.count } + + user_2 = Fabricate(:user) + Fabricate(:reaction_user, reaction: cry, user: user_2) + expect { described_class.new(cry, user_1).create }.not_to change { Notification.count } + expect(JSON.parse(Notification.last.data)["display_username"]).to eq(user_1.username) + + DiscourseReactions::ReactionUser.find_by(reaction: cry, user: user_1).destroy + DiscourseReactions::ReactionUser.find_by(reaction: thumbsup, user: user_1).destroy + expect do + described_class.new(cry, user_1).delete + described_class.new(thumbsup, user_1).delete + end.not_to change { Notification.count } + expect(JSON.parse(Notification.last.data)["display_username"]).to eq(user_2.username) + expect(Notification.last.notification_type).to eq(Notification.types[:reaction]) + + DiscourseReactions::ReactionUser.find_by(reaction: cry, user: user_2).destroy + expect { described_class.new(cry, user_2).delete }.to change { Notification.count }.by(-1) + end + + it "displays the full name" do + cry_p1 = Fabricate(:reaction, post: post_1, reaction_value: "cry") + + described_class.new(cry_p1, user_2).create + + expect( + Notification.where(notification_type: Notification.types[:reaction], user: post_1.user).count, + ).to eq(1) + + notification = Notification.where(notification_type: Notification.types[:reaction]).last + expect(notification.data_hash[:display_name]).to eq(user_2.name) + end + + it "adds the heart icon when the remaining notification is a like" do + Fabricate(:reaction_user, reaction: like_reaction, user: user_2) + described_class.new(like_reaction, user_2).create + + DiscourseReactions::ReactionUser.find_by(reaction: thumbsup, user: user_1).destroy! + described_class.new(thumbsup, user_1).delete + + remaining_notification = + Notification.where(notification_type: Notification.types[:reaction]).last + + expect(remaining_notification.data_hash[:reaction_icon]).to eq(like_reaction.reaction_value) + end + + it "doesn't add the heart icon when not all remaining notifications are likes" do + Fabricate(:reaction_user, reaction: like_reaction, user: user_2) + described_class.new(like_reaction, user_2).create + + cry = Fabricate(:reaction, post: post_1, reaction_value: "cry") + Fabricate(:reaction_user, reaction: cry, user: user_3) + described_class.new(cry, user_3).create + + DiscourseReactions::ReactionUser.find_by(reaction: thumbsup, user: user_1).destroy! + described_class.new(thumbsup, user_1).delete + + remaining_notification = + Notification.where(notification_type: Notification.types[:reaction]).last + + expect(remaining_notification.data_hash[:reaction_icon]).to be_nil + end + + describe "consolidating reaction notifications" do + fab!(:post_2) { Fabricate(:post, user: post_1.user) } + let!(:cry_p1) { Fabricate(:reaction, post: post_1, reaction_value: "cry") } + let!(:cry_p2) { Fabricate(:reaction, post: post_2, reaction_value: "cry") } + + describe "multiple reactions from the same user" do + before { SiteSetting.notification_consolidation_threshold = 1 } + + it "consolidates notifications from the same user" do + described_class.new(cry_p1, user_2).create + described_class.new(cry_p2, user_2).create + + expect( + Notification.where( + notification_type: Notification.types[:reaction], + user: post_1.user, + ).count, + ).to eq(1) + consolidated_notification = + Notification.where(notification_type: Notification.types[:reaction]).last + + expect(consolidated_notification.data_hash[:consolidated]).to eq(true) + expect(consolidated_notification.data_hash[:username]).to eq(user_2.username) + end + + it "doesn't update a consolidated notification when a different user reacts to a post" do + described_class.new(cry_p1, user_2).create + described_class.new(cry_p2, user_2).create + described_class.new(cry_p2, user_3).create + + expect( + Notification.where( + notification_type: Notification.types[:reaction], + user: post_1.user, + ).count, + ).to eq(2) + consolidated_notification = + Notification.where(notification_type: Notification.types[:reaction]).last + + expect(consolidated_notification.data_hash[:consolidated]).to be_nil + expect(consolidated_notification.data_hash[:display_username]).to eq(user_3.username) + end + + it "keeps the reaction icon when consolidating multiple likes from the same user" do + like_reaction_p2 = Fabricate(:reaction, post: post_2, reaction_value: "heart") + + described_class.new(like_reaction, user_2).create + described_class.new(like_reaction_p2, user_2).create + + consolidated_notification = + Notification.where(notification_type: Notification.types[:reaction]).last + + expect(consolidated_notification.data_hash[:consolidated]).to eq(true) + expect(consolidated_notification.data_hash[:reaction_icon]).to eq( + like_reaction.reaction_value, + ) + end + + it "doesn't add the reaction icon when consolidating a non-like and a like notification" do + described_class.new(cry_p2, user_2).create + described_class.new(like_reaction, user_2).create + + consolidated_notification = + Notification.where(notification_type: Notification.types[:reaction]).last + + expect(consolidated_notification.data_hash[:reaction_icon]).to be_nil + end + + it "removes the reaction icon when updating a like consolidated notification with a different reactions" do + like_reaction_p2 = Fabricate(:reaction, post: post_2, reaction_value: "heart") + post_3 = Fabricate(:post, user: post_1.user) + cry_p3 = Fabricate(:reaction, post: post_3, reaction_value: "cry") + + described_class.new(like_reaction, user_2).create + described_class.new(like_reaction_p2, user_2).create + described_class.new(cry_p3, user_2).create + + consolidated_notification = + Notification.where(notification_type: Notification.types[:reaction]).last + + expect(consolidated_notification.data_hash[:reaction_icon]).to be_nil + end + end + + describe "multiple users reacting to the same post" do + before do + post_1.user.user_option.update!( + like_notification_frequency: UserOption.like_notification_frequency_type[:always], + ) + end + + it "keeps one notification pointing to the two last users that reacted to a post" do + described_class.new(cry_p1, user_2).create + described_class.new(thumbsup, user_3).create + + expect( + Notification.where( + notification_type: Notification.types[:reaction], + user: post_1.user, + ).count, + ).to eq(1) + + consolidated_notification = + Notification.where(notification_type: Notification.types[:reaction]).last + + expect(consolidated_notification.data_hash[:display_username]).to eq(user_3.username) + expect(consolidated_notification.data_hash[:username2]).to eq(user_2.username) + expect(consolidated_notification.data_hash[:display_name]).to eq(user_3.name) + expect(consolidated_notification.data_hash[:name2]).to eq(user_2.name) + end + + it "creates a new notification if the last one was created more than one day ago" do + first_notification = described_class.new(cry_p1, user_2).create + first_notification.update!(created_at: 2.days.ago) + + described_class.new(thumbsup, user_3).create + + expect( + Notification.where( + notification_type: Notification.types[:reaction], + user: post_1.user, + ).count, + ).to eq(2) + end + + it "keeps the icon of the last notification" do + described_class.new(thumbsup, user_3).create + described_class.new(like_reaction, user_2).create + + consolidated_notification = + Notification.where(notification_type: Notification.types[:reaction]).last + + expect(consolidated_notification.data_hash[:reaction_icon]).to eq( + like_reaction.reaction_value, + ) + end + end + end + + describe "stores the icon in the notification payload" do + it "stores the heart icon for like reactions" do + described_class.new(like_reaction, user_2).create + notification = + Notification.where(user: post_1.user, notification_type: Notification.types[:reaction]).last + + expect(notification.data_hash[:reaction_icon]).to eq(like_reaction.reaction_value) + end + end +end diff --git a/plugins/discourse-reactions/spec/system/core_features_spec.rb b/plugins/discourse-reactions/spec/system/core_features_spec.rb new file mode 100644 index 00000000000..ca779bf1ad4 --- /dev/null +++ b/plugins/discourse-reactions/spec/system/core_features_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe "Core features", type: :system do + before { enable_current_plugin } + + it_behaves_like "having working core features", skip_examples: %i[likes] +end diff --git a/plugins/discourse-reactions/spec/system/page_objects/post_reactions_list.rb b/plugins/discourse-reactions/spec/system/page_objects/post_reactions_list.rb new file mode 100644 index 00000000000..9aaf640ef8d --- /dev/null +++ b/plugins/discourse-reactions/spec/system/page_objects/post_reactions_list.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class PostReactionsList < PageObjects::Components::Base + attr_reader :context + + SELECTOR = ".discourse-reactions-list" + + def initialize(context) + @context = context + end + + def component + context_component.find(SELECTOR) + end + + def context_component + page.find(@context) + end + + def post_id + context_component["data-post-id"] + end + + def reaction_list_emoji_selector(reaction) + "#discourse-reactions-list-emoji-#{post_id}-#{reaction}" + end + + def has_reaction?(reaction) + component.has_css?(reaction_list_emoji_selector(reaction)) + end + + def hover_over_reaction(reaction) + component.find(reaction_list_emoji_selector(reaction)).hover + page.has_css?(".discourse-reactions-list-emoji .user-list", visible: true) + end + + def click_reaction(reaction) + component.find(reaction_list_emoji_selector(reaction)).click + end + + def has_users_for_reaction?(reaction, usernames) + find("#{reaction_list_emoji_selector(reaction)} .user-list .container").has_text?( + usernames.join("\n"), + ) + end + end + end +end diff --git a/plugins/discourse-reactions/spec/system/reaction_notifications_spec.rb b/plugins/discourse-reactions/spec/system/reaction_notifications_spec.rb new file mode 100644 index 00000000000..5029002af36 --- /dev/null +++ b/plugins/discourse-reactions/spec/system/reaction_notifications_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +describe "Reactions | Notifications", type: :system, js: true do + fab!(:current_user) { Fabricate(:user) } + fab!(:acting_user_1) { Fabricate(:user, name: "Bruce Wayne I") } + fab!(:acting_user_2) { Fabricate(:user, name: "Bruce Wayne II") } + + fab!(:one_reaction_notification) do + Fabricate(:one_reaction_notification, user: current_user, acting_user: acting_user_1) + end + + fab!(:two_reactions_notification) do + Fabricate( + :multiple_reactions_notification, + user: current_user, + count: 2, + acting_user: acting_user_1, + acting_user_2: acting_user_2, + ) + end + + fab!(:three_reactions_notification) do + Fabricate( + :multiple_reactions_notification, + user: current_user, + count: 3, + acting_user: acting_user_1, + acting_user_2: acting_user_2, + ) + end + + let(:user_menu) { PageObjects::Components::UserMenu.new } + + before do + SiteSetting.discourse_reactions_enabled = true + sign_in(current_user) + end + + it "renders reactions notifications correctly in the user menu" do + visit("/") + + user_menu.open + + expect(page).to have_css( + "#quick-access-all-notifications .notification.reaction .item-label", + count: 3, + ) + + labels = page.all("#quick-access-all-notifications .notification.reaction .item-label") + expect(labels[0]).to have_text( + I18n.t( + "js.notifications.reaction_multiple_users", + username: acting_user_2.username, + count: 2, + ), + ) + expect(labels[1]).to have_text( + I18n.t( + "js.notifications.reaction_2_users", + username: acting_user_2.username, + username2: acting_user_1.username, + ), + ) + expect(labels[2]).to have_text(acting_user_1.username) + end + + context "when prioritize full names in ux site setting is on" do + fab!(:acting_user_3) { Fabricate(:user, username: "missingno", name: nil) } + fab!(:null_name_notification) do + Fabricate( + :multiple_reactions_notification, + user: current_user, + count: 2, + acting_user: acting_user_3, + acting_user_2: acting_user_2, + ) + end + + before { SiteSetting.prioritize_full_name_in_ux = true } + + it "renders reaction notifications with full names" do + visit("/") + user_menu.open + + labels = page.all("#quick-access-all-notifications .notification.reaction .item-label") + + expect(labels[1]).to have_text( + I18n.t("js.notifications.reaction_multiple_users", username: acting_user_2.name, count: 2), + ) + + expect(labels[2]).to have_text( + I18n.t( + "js.notifications.reaction_2_users", + username: acting_user_2.name, + username2: acting_user_1.name, + ), + ) + + expect(labels[2]).to have_text(acting_user_1.name) + end + + it "falls back to the username when name field is nil" do + visit("/") + user_menu.open + + labels = page.all("#quick-access-all-notifications .notification.reaction .item-label") + expect(labels[0]).to have_text(acting_user_3.username) + end + end +end diff --git a/plugins/discourse-reactions/spec/system/reactions_activity_spec.rb b/plugins/discourse-reactions/spec/system/reactions_activity_spec.rb new file mode 100644 index 00000000000..0094530e4e5 --- /dev/null +++ b/plugins/discourse-reactions/spec/system/reactions_activity_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +describe "Reactions | Activity", type: :system, js: true do + fab!(:current_user) { Fabricate(:user) } + + before do + SiteSetting.discourse_reactions_enabled = true + + sign_in(current_user) + end + + context "when current user reacts to a post" do + fab!(:post_1) { Fabricate(:post) } + before do + DiscourseReactions::ReactionManager.new( + reaction_value: "clap", + user: current_user, + post: post_1, + ).toggle! + end + + it "shows in activity" do + visit("/u/#{current_user.username}/activity/reactions") + + expect(page).to have_css(".user-stream-item [data-post-id='#{post_1.id}']") + end + + context "when unicode usernames is enabled " do + before do + SiteSetting.external_system_avatars_enabled = true + SiteSetting.external_system_avatars_url = + "/letter_avatar_proxy/v4/letter/{first_letter}/{color}/{size}.png" + SiteSetting.unicode_usernames = true + end + + context "when prioritize_full_name_in_ux SiteSetting is true" do + before do + SiteSetting.prioritize_full_name_in_ux = true + stub_request( + :get, + "https://avatars.discourse-cdn.com/v4/letter/b/dbc845/48.png", + ).to_return(status: 200, body: "image", headers: {}) + + stub_request( + :get, + "https://avatars.discourse-cdn.com/v4/letter/b/90ced4/48.png", + ).to_return(status: 200, body: "image", headers: {}) + + stub_request( + :get, + "https://avatars.discourse-cdn.com/v4/letter/b/90ced4/24.png", + ).to_return(status: 200, body: "image", headers: {}) + + stub_request( + :get, + "https://avatars.discourse-cdn.com/v4/letter/b/90ced4/144.png", + ).to_return(status: 200, body: "image", headers: {}) + + stub_request( + :get, + "https://avatars.discourse-cdn.com/v4/letter/b/9de053/48.png", + ).to_return(status: 200, body: "image", headers: {}) + end + + it "shows the name of the mentioned user instead of the username" do + unicode_user = Fabricate(:user) + post_2 = + Fabricate(:post, raw: "This is a test post with a mention @#{unicode_user.username}") + DiscourseReactions::ReactionManager.new( + reaction_value: "clap", + user: current_user, + post: post_2, + ).toggle! + + visit("/u/#{current_user.username}/activity/reactions") + + post = find(".user-stream-item [data-post-id='#{post_2.id}']") + expect(page).to have_css(".user-stream-item [data-post-id='#{post_2.id}']") + expect(post).to have_content("@Bruce Wayne") + end + end + end + + context "when the associated post is deleted" do + before { post_1.trash! } + + it "doesn't show it" do + visit("/u/#{current_user.username}/activity/reactions") + + expect(page).to have_no_css(".user-stream-item [data-post-id='#{post_1.id}']") + end + end + + context "when the associated topic is deleted" do + before { post_1.topic.trash! } + + it "doesn't show it" do + visit("/u/#{current_user.username}/activity/reactions") + + expect(page).to have_no_css(".user-stream-item [data-post-id='#{post_1.id}']") + end + end + end +end diff --git a/plugins/discourse-reactions/spec/system/reactions_post_list_spec.rb b/plugins/discourse-reactions/spec/system/reactions_post_list_spec.rb new file mode 100644 index 00000000000..7275db70d4b --- /dev/null +++ b/plugins/discourse-reactions/spec/system/reactions_post_list_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +describe "Reactions | Post reaction user list", type: :system, js: true do + fab!(:current_user) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user) } + fab!(:post) { Fabricate(:post, user: current_user) } + + let(:reactions_list) do + PageObjects::Components::PostReactionsList.new("#post_#{post.post_number}") + end + + before do + SiteSetting.discourse_reactions_enabled = true + + DiscourseReactions::ReactionManager.new( + reaction_value: "heart", + user: user_2, + post: post, + ).toggle! + DiscourseReactions::ReactionManager.new( + reaction_value: "clap", + user: user_3, + post: post, + ).toggle! + end + + %w[enabled disabled].each do |value| + before { SiteSetting.glimmer_post_stream_mode = value } + + context "when glimmer_post_stream_mode=#{value}" do + it "shows a list of users who have reacted to a post on hover for likes and each reaction" do + sign_in(current_user) + visit(post.url) + + expect(reactions_list).to have_reaction("heart") + expect(reactions_list).to have_reaction("clap") + + reactions_list.hover_over_reaction("heart") + expect(reactions_list).to have_users_for_reaction("heart", [user_2.username]) + + # hover on something else to clear the current hover + page.find("#site-logo").hover + + reactions_list.hover_over_reaction("clap") + expect(reactions_list).to have_users_for_reaction("clap", [user_3.username]) + end + + it "shows more info about reactions when clicking" do + visit(post.url) + expect(reactions_list).to have_reaction("heart") + reactions_list.click_reaction("heart") + + expect(page).to have_css(".discourse-reactions-state-panel") + find(".discourse-reactions-state-panel [data-user-card=#{user_2.username}]").click + + expect(page).to have_css(".user-card.user-card-#{user_2.username}") + end + + context "when the site allows anonymous users to like posts" do + before do + SiteSetting.allow_anonymous_mode = true + SiteSetting.allow_likes_in_anonymous_mode = true + end + + it "shows a list of users who have liked a post on hover for unauthenticated users" do + visit(post.url) + + expect(reactions_list).to have_reaction("heart") + + reactions_list.hover_over_reaction("heart") + expect(reactions_list).to have_users_for_reaction("heart", [user_2.username]) + end + + it "shows a list of users who have liked a post on hover for authenticated users posting anonymously" do + anonymous_user = Fabricate(:anonymous) + sign_in(anonymous_user) + visit(post.url) + + expect(reactions_list).to have_reaction("heart") + + reactions_list.hover_over_reaction("heart") + expect(reactions_list).to have_users_for_reaction("heart", [user_2.username]) + end + end + end + end +end diff --git a/plugins/discourse-reactions/test/javascripts/acceptance/deleted-post-test.js b/plugins/discourse-reactions/test/javascripts/acceptance/deleted-post-test.js new file mode 100644 index 00000000000..af2aa2d7347 --- /dev/null +++ b/plugins/discourse-reactions/test/javascripts/acceptance/deleted-post-test.js @@ -0,0 +1,30 @@ +/* eslint-disable qunit/no-loose-assertions */ +import { visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { default as ReactionsTopics } from "../fixtures/reactions-topic-fixtures"; + +acceptance("Discourse Reactions - Deleted post", function (needs) { + needs.user(); + + needs.settings({ + discourse_reactions_enabled: true, + discourse_reactions_enabled_reactions: "otter|open_mouth", + discourse_reactions_reaction_for_like: "heart", + discourse_reactions_like_icon: "heart", + }); + + needs.pretender((server, helper) => { + const topicPath = "/t/374.json"; + server.get(topicPath, () => helper.response(ReactionsTopics[topicPath])); + }); + + test("Reaction controls", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.notOk( + exists("#post_4 .discourse-reactions-actions"), + "reaction controls are not shown" + ); + }); +}); diff --git a/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-disabled-test.js b/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-disabled-test.js new file mode 100644 index 00000000000..c30c608e953 --- /dev/null +++ b/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-disabled-test.js @@ -0,0 +1,35 @@ +/* eslint-disable qunit/no-loose-assertions */ +import { visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { default as ReactionsTopics } from "../fixtures/reactions-topic-fixtures"; + +["enabled", "disabled"].forEach((postStreamMode) => { + acceptance( + `Discourse Reactions - Disabled (glimmer_post_stream_mode = ${postStreamMode})`, + function (needs) { + needs.user(); + + needs.settings({ + discourse_reactions_enabled: false, + glimmer_post_stream_mode: postStreamMode, + }); + + needs.pretender((server, helper) => { + const topicPath = "/t/374.json"; + server.get(topicPath, () => + helper.response(ReactionsTopics[topicPath]) + ); + }); + + test("Does not show reactions controls", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.notOk( + exists(".discourse-reactions-actions"), + "reactions controls are not shown" + ); + }); + } + ); +}); diff --git a/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-enabled-test.js b/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-enabled-test.js new file mode 100644 index 00000000000..363bdfd9468 --- /dev/null +++ b/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-enabled-test.js @@ -0,0 +1,65 @@ +/* eslint-disable qunit/no-loose-assertions */ +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { default as ReactionsTopics } from "../fixtures/reactions-topic-fixtures"; + +["enabled", "disabled"].forEach((postStreamMode) => { + acceptance( + `Discourse Reactions - Enabled (glimmer_post_stream_mode = ${postStreamMode})`, + function (needs) { + needs.user(); + + needs.settings({ + discourse_reactions_enabled: true, + discourse_reactions_enabled_reactions: "otter|open_mouth", + discourse_reactions_reaction_for_like: "heart", + discourse_reactions_like_icon: "heart", + glimmer_post_stream_mode: postStreamMode, + }); + + needs.pretender((server, helper) => { + const topicPath = "/t/374.json"; + server.get(topicPath, () => + helper.response(ReactionsTopics[topicPath]) + ); + }); + + test("It shows reactions controls", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.ok( + exists(".discourse-reactions-actions"), + "reaction controls are available" + ); + }); + } + ); + + acceptance( + `Discourse Reactions - Enabled | Anonymous user (glimmer_post_stream_mode = ${postStreamMode})`, + function (needs) { + needs.settings({ + discourse_reactions_enabled: true, + discourse_reactions_enabled_reactions: "otter|open_mouth", + discourse_reactions_reaction_for_like: "heart", + discourse_reactions_like_icon: "heart", + glimmer_post_stream_mode: postStreamMode, + }); + + needs.pretender((server, helper) => { + const topicPath = "/t/374.json"; + server.get(topicPath, () => + helper.response(ReactionsTopics[topicPath]) + ); + }); + + test("It shows reactions controls", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + await click(".actions button.btn-toggle-reaction-like"); + + assert.dom("#login-form").exists("login form was displayed"); + }); + } + ); +}); diff --git a/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-notifications-test.js b/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-notifications-test.js new file mode 100644 index 00000000000..91d45f16996 --- /dev/null +++ b/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-notifications-test.js @@ -0,0 +1,332 @@ +/* eslint-disable qunit/no-loose-assertions */ +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { i18n } from "discourse-i18n"; + +acceptance("Discourse Reactions - Notifications", function (needs) { + needs.user(); + + needs.settings({ + discourse_reactions_enabled: true, + discourse_reactions_enabled_reactions: "otter|open_mouth", + discourse_reactions_reaction_for_like: "heart", + discourse_reactions_like_icon: "heart", + }); + + needs.pretender((server, helper) => { + server.get("/notifications", () => { + return helper.response({ + notifications: [ + { + id: 1334, + user_id: 88, + notification_type: 25, + read: true, + high_priority: false, + created_at: "2022-08-18T13:00:11.166Z", + post_number: 12, + topic_id: 8432, + fancy_title: "Topic with one reaction from a user", + slug: "topic-with-one-reaction-from-a-user", + data: { + topic_title: "Topic with one reaction from a user", + original_post_id: 3349, + original_post_type: 1, + original_username: "krus", + revision_number: null, + display_username: "krus", + reaction_icon: "heart", + previous_notification_id: 933, + count: 1, + }, + }, + { + id: 842, + user_id: 88, + notification_type: 25, + read: true, + high_priority: false, + created_at: "2021-08-19T23:00:11.166Z", + post_number: 3, + topic_id: 138, + fancy_title: "Topic with 2 likes (total) from 2 users", + slug: "topic-with-2-likes-total-from-2-users", + data: { + topic_title: "Topic with 2 likes (total) from 2 users", + original_post_id: 443, + original_post_type: 1, + original_username: "jammed-radio", + revision_number: null, + display_username: "jammed-radio", + previous_notification_id: 933, + username2: "broken-radio", + reaction_icon: "heart", + count: 2, + }, + }, + { + id: 3843, + user_id: 88, + notification_type: 25, + read: true, + high_priority: false, + created_at: "2021-08-19T23:00:11.166Z", + post_number: 31, + topic_id: 832, + fancy_title: "Topic with likes from multiple users", + slug: "topic-with-likes-from-multiple-users", + data: { + topic_title: "Topic with likes from multiple users", + original_post_id: 903, + original_post_type: 1, + original_username: "jam-and-cheese", + revision_number: null, + display_username: "jam-and-cheese", + previous_notification_id: 933, + username2: "cheesy-monkey", + reaction_icon: "heart", + count: 3, + }, + }, + { + id: 2189, + user_id: 88, + notification_type: 25, + read: true, + high_priority: false, + created_at: "2020-11-13T03:10:41.166Z", + post_number: 31, + topic_id: 913, + fancy_title: "Topic with likes and reactions", + slug: "topic-with-likes-and-reactions", + data: { + topic_title: "Topic with likes and reactions", + original_post_id: 384, + original_post_type: 1, + original_username: "nuclear-reactor", + revision_number: null, + display_username: "nuclear-reactor", + previous_notification_id: 933, + username2: "solar-engine", + count: 4, + }, + }, + { + id: 7731, + user_id: 88, + notification_type: 25, + read: true, + high_priority: false, + created_at: "2022-07-18T10:00:11.186Z", + post_number: null, + topic_id: null, + fancy_title: null, + slug: null, + data: { + topic_title: "Double reactions on multiple posts from one user", + original_post_id: 843, + original_post_type: 1, + original_username: "johnny", + revision_number: null, + display_username: "johnny", + username: "johnny", + consolidated: true, + count: 2, + }, + }, + ], + }); + }); + }); + + test("reaction notifications", async (assert) => { + await visit("/"); + await click(".d-header-icons .current-user button"); + + const notifications = queryAll( + "#quick-access-all-notifications ul li.notification.reaction a" + ); + + assert.strictEqual(notifications.length, 5); + + assert.strictEqual( + notifications[0].textContent.replaceAll(/\s+/g, " ").trim(), + "krus Topic with one reaction from a user", + "notification for one like from one user has the right content" + ); + assert.ok( + notifications[0].href.endsWith( + "/t/topic-with-one-reaction-from-a-user/8432/12" + ), + "notification for one like from one user links to the topic" + ); + assert.ok( + notifications[0].querySelector(".d-icon-heart"), + "notification for one like from one user has heart icon" + ); + + assert.strictEqual( + notifications[1].textContent.replaceAll(/\s+/g, " ").trim(), + `${i18n("notifications.reaction_2_users", { + username: "jammed-radio", + username2: "broken-radio", + })} Topic with 2 likes (total) from 2 users`, + "notification for 2 likes from 2 users has the right content" + ); + assert.ok( + notifications[1].href.endsWith( + "/t/topic-with-2-likes-total-from-2-users/138/3" + ), + "notification for 2 likes from 2 users links to the topic" + ); + assert.ok( + notifications[1].querySelector(".d-icon-heart"), + "notification for 2 likes from 2 users has the heart icon" + ); + + assert.strictEqual( + notifications[2].textContent.replaceAll(/\s+/g, " ").trim(), + `${i18n("notifications.reaction_multiple_users", { + username: "jam-and-cheese", + count: 2, + })} Topic with likes from multiple users`, + "notification for likes from 3 or more users has the right content" + ); + assert.ok( + notifications[2].href.endsWith( + "/t/topic-with-likes-from-multiple-users/832/31" + ), + "notification for likes from 3 or more users links to the topic" + ); + assert.ok( + notifications[2].querySelector(".d-icon-heart"), + "notification for 2 likes from 3 or more users has the heart icon" + ); + + assert.strictEqual( + notifications[3].textContent.replaceAll(/\s+/g, " ").trim(), + `${i18n("notifications.reaction_multiple_users", { + username: "nuclear-reactor", + count: 3, + })} Topic with likes and reactions`, + "notification for reactions from 3 or more users has the right content" + ); + assert.ok( + notifications[3].href.endsWith( + "/t/topic-with-likes-and-reactions/913/31" + ), + "notification for reactions from 3 or more users links to the topic" + ); + assert.ok( + notifications[3].querySelector(".d-icon-discourse-emojis"), + "notification for 2 reactions from 3 or more users has the emojis icon" + ); + + assert.strictEqual( + notifications[4].textContent.replaceAll(/\s+/g, " ").trim(), + `johnny ${i18n("notifications.reaction_1_user_multiple_posts", { + count: 2, + })}`, + "notification for reactions from 1 users on multiple posts has the right content" + ); + assert.ok( + notifications[4].href.endsWith( + "/u/eviltrout/notifications/reactions-received?acting_username=johnny&include_likes=true" + ), + "notification for reactions from 1 users on multiple posts links the reactions-received page of the user" + ); + assert.ok( + notifications[4].querySelector(".d-icon-discourse-emojis"), + "notification for reactions from 1 users on multiple posts has the emojis icon" + ); + }); +}); + +acceptance( + "Discourse Reactions - Notifications | Full Name Setting On", + function (needs) { + needs.user(); + + needs.settings({ + discourse_reactions_enabled: true, + discourse_reactions_enabled_reactions: "otter|open_mouth", + discourse_reactions_reaction_for_like: "heart", + discourse_reactions_like_icon: "heart", + prioritize_full_name_in_ux: true, + }); + + needs.pretender((server, helper) => { + server.get("/notifications", () => { + return helper.response({ + notifications: [ + { + id: 842, + user_id: 88, + notification_type: 25, + read: true, + high_priority: false, + created_at: "2021-08-19T23:00:11.166Z", + post_number: 3, + topic_id: 138, + fancy_title: "Topic with 2 likes (total) from 2 users", + slug: "topic-with-2-likes-total-from-2-users", + data: { + topic_title: "Topic with 2 likes (total) from 2 users", + original_post_id: 443, + original_post_type: 1, + original_username: "jammed-radio", + revision_number: null, + display_username: "jammed-radio", + display_name: "Bruce Wayne I", + previous_notification_id: 933, + username2: "broken-radio", + name2: "Brucer Wayner II", + reaction_icon: "heart", + count: 2, + }, + }, + { + id: 2189, + user_id: 88, + notification_type: 25, + read: true, + high_priority: false, + created_at: "2020-11-13T03:10:41.166Z", + post_number: 31, + topic_id: 913, + fancy_title: "Topic with likes and reactions", + slug: "topic-with-likes-and-reactions", + data: { + topic_title: "Topic with likes and reactions", + original_post_id: 384, + original_post_type: 1, + original_username: "nuclear-reactor", + revision_number: null, + display_username: "nuclear-reactor", + display_name: "Monkey D. Luffy", + previous_notification_id: 933, + username2: "solar-engine", + name2: "Roronoa Zoro", + count: 4, + }, + }, + ], + }); + }); + }); + + test("reaction notifications with full name site setting on", async function (assert) { + await visit("/"); + await click(".d-header-icons .current-user button"); + + assert + .dom("li.notification.reaction:nth-child(1) a") + .hasText(/Bruce Wayne I, Brucer Wayner II/); + + assert + .dom("li.notification.reaction:nth-child(2) a") + .hasText(/Monkey D. Luffy and 3 others/); + }); + } +); diff --git a/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-post-test.js b/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-post-test.js new file mode 100644 index 00000000000..45d26c4427d --- /dev/null +++ b/plugins/discourse-reactions/test/javascripts/acceptance/discourse-reactions-post-test.js @@ -0,0 +1,138 @@ +/* eslint-disable qunit/no-assert-equal */ +/* eslint-disable qunit/no-loose-assertions */ +import { visit } from "@ember/test-helpers"; +import { skip, test } from "qunit"; +import { + acceptance, + exists, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import { default as ReactionsTopics } from "../fixtures/reactions-topic-fixtures"; + +["enabled", "disabled"].forEach((postStreamMode) => { + acceptance( + `Discourse Reactions - Post (glimmer_post_stream_mode = ${postStreamMode})`, + function (needs) { + needs.user(); + + needs.settings({ + discourse_reactions_enabled: true, + discourse_reactions_enabled_reactions: "otter|open_mouth", + discourse_reactions_reaction_for_like: "heart", + discourse_reactions_like_icon: "heart", + glimmer_post_stream_mode: postStreamMode, + }); + + needs.pretender((server, helper) => { + const topicPath = "/t/374.json"; + server.get(topicPath, () => + helper.response(ReactionsTopics[topicPath]) + ); + }); + + test("Reactions count", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.equal( + queryAll( + "#post_1 .discourse-reactions-counter .reactions-counter" + ).text(), + 209, + "it displays the correct count" + ); + }); + + skip("Reactions list", async (assert) => { + const reactions = []; + const expectedSequence = + "heart|angry|laughing|open_mouth|cry|thumbsdown|nose:t2|thumbsup"; + + await visit("/t/topic_with_reactions_and_likes/374"); + + queryAll( + "#post_1 .discourse-reactions-counter .discourse-reactions-list .reactions .discourse-reactions-list-emoji" + ).map((index, currentValue) => { + reactions.push(currentValue.innerText); + }); + + assert.equal( + reactions.join("|"), + expectedSequence, + "it displays the correct list sorted by count" + ); + }); + + test("Other user post", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.ok( + exists("#post_2 .discourse-reactions-reaction-button"), + "it displays the reaction button" + ); + }); + + test("Post is yours", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.notOk( + exists("#post_1 .discourse-reactions-reaction-button"), + "it does not display the reaction button" + ); + }); + + test("Post has only likes (no reactions)", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.ok( + exists("#post_3 .discourse-reactions-double-button"), + "it displays the reaction count beside the reaction button" + ); + }); + + test("Post has likes and reactions", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.notOk( + exists("#post_1 .discourse-reactions-double-button"), + "it does not display the reaction count beside the reaction button" + ); + }); + + test("Current user has no reaction on post and can toggle", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.ok( + exists("#post_2 .discourse-reactions-actions.can-toggle-reaction"), + "it allows to toggle the reaction" + ); + }); + + test("Current user has no reaction on post and can toggle", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.ok( + exists("#post_2 .discourse-reactions-actions.can-toggle-reaction"), + "it allows to toggle the reaction" + ); + }); + + test("Current user can undo on post and can toggle", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.ok( + exists("#post_3 .discourse-reactions-actions.can-toggle-reaction"), + "it allows to toggle the reaction" + ); + }); + + test("Current user can't toggle", async (assert) => { + await visit("/t/topic_with_reactions_and_likes/374"); + + assert.notOk( + exists("#post_1 .discourse-reactions-actions.can-toggle-reaction"), + "it doesn’t allow to toggle the reaction" + ); + }); + } + ); +}); diff --git a/plugins/discourse-reactions/test/javascripts/fixtures/reactions-topic-fixtures.js b/plugins/discourse-reactions/test/javascripts/fixtures/reactions-topic-fixtures.js new file mode 100644 index 00000000000..7428a908375 --- /dev/null +++ b/plugins/discourse-reactions/test/javascripts/fixtures/reactions-topic-fixtures.js @@ -0,0 +1,819 @@ +export default { + "/t/374.json": { + post_stream: { + posts: [ + { + id: 854, + name: "Ahmed Gagan", + username: "ahmedgagan6", + avatar_template: "/user_avatar/localhost/ahmedgagan6/{size}/4_2.png", + created_at: "2021-03-10T20:35:28.721Z", + cooked: "

This is a test topic

", + post_number: 1, + post_type: 1, + updated_at: "2021-03-10T21:16:11.840Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 0, + reads: 4, + readers_count: 3, + score: 2850.8, + yours: true, + topic_id: 374, + topic_slug: "discourse-reactions-beyond-likes", + display_username: "Ahmed Gagan", + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_bg_color: null, + primary_group_flair_color: null, + version: 3, + can_edit: true, + can_delete: false, + can_recover: false, + can_wiki: true, + link_counts: [ + { + url: "/groups/team", + internal: true, + reflection: false, + clicks: 0, + }, + { + url: "https://meta.discourse.org/t/install-plugins-in-discourse/19157", + internal: false, + reflection: false, + title: "Install Plugins in Discourse - admins - Discourse Meta", + clicks: 0, + }, + { + url: "https://github.com/discourse/discourse-reactions", + internal: false, + reflection: false, + title: "GitHub - discourse/discourse-reactions", + clicks: 0, + }, + ], + read: true, + user_title: "team", + title_is_group: false, + bookmarked: false, + actions_summary: [ + { + id: 2, + count: 190, + }, + { + id: 3, + can_act: true, + }, + { + id: 4, + can_act: true, + }, + { + id: 8, + can_act: true, + }, + { + id: 7, + can_act: true, + }, + ], + moderator: false, + admin: true, + staff: true, + user_id: 1, + hidden: false, + trust_level: 2, + deleted_at: null, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + reviewable_id: 0, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + reactions: [ + { + id: "heart", + type: "emoji", + count: 190, + }, + { + id: "angry", + type: "emoji", + count: 5, + }, + { + id: "laughing", + type: "emoji", + count: 5, + }, + { + id: "open_mouth", + type: "emoji", + count: 3, + }, + { + id: "cry", + type: "emoji", + count: 2, + }, + { + id: "thumbsdown", + type: "emoji", + count: 2, + }, + { + id: "nose:t2", + type: "emoji", + count: 1, + }, + { + id: "thumbsup", + type: "emoji", + count: 1, + }, + ], + current_user_reaction: null, + reaction_users_count: 209, + current_user_used_main_reaction: false, + }, + { + id: 1076, + name: "Sam Saffron", + username: "sam", + avatar_template: "/user_avatar/localhost/sam/{size}/5_2.png", + created_at: "2021-03-10T21:40:09.152Z", + cooked: + '

This looks pretty cool :star_struck:

', + post_number: 2, + post_type: 1, + updated_at: "2021-03-10T21:40:09.152Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 0, + reads: 4, + readers_count: 3, + score: 15.8, + yours: false, + topic_id: 374, + topic_slug: "discourse-reactions-beyond-likes", + display_username: "Sam Saffron", + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_bg_color: null, + primary_group_flair_color: null, + version: 1, + can_edit: true, + can_delete: true, + can_recover: false, + can_wiki: true, + read: true, + user_title: "Leader", + title_is_group: false, + bookmarked: false, + actions_summary: [ + { + id: 2, + count: 1, + can_act: true, + }, + { + id: 3, + can_act: true, + }, + { + id: 4, + can_act: true, + }, + { + id: 8, + can_act: true, + }, + { + id: 6, + can_act: true, + }, + { + id: 7, + can_act: true, + }, + ], + moderator: false, + admin: false, + staff: false, + user_id: 3, + hidden: false, + trust_level: 4, + deleted_at: null, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + notice: { + type: "returning_user", + last_posted_at: "2020-09-14T19:49:25Z", + }, + reviewable_id: 0, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + reactions: [ + { + id: "heart", + type: "emoji", + count: 1, + }, + ], + current_user_reaction: null, + reaction_users_count: 1, + current_user_used_main_reaction: false, + }, + { + id: 1078, + name: "Joffrey Jaffeux", + username: "joffreyjaffeux", + avatar_template: + "/user_avatar/localhost/joffreyjaffeux/{size}/11_2.png", + created_at: "2021-03-10T21:50:09.280Z", + cooked: + '

Thanks, this a great plugin :heart_eyes:

', + post_number: 3, + post_type: 1, + updated_at: "2021-03-10T21:50:09.280Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 0, + reads: 4, + readers_count: 3, + score: 60.8, + yours: false, + topic_id: 374, + topic_slug: "discourse-reactions-beyond-likes", + display_username: "Joffrey Jaffeux", + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_bg_color: null, + primary_group_flair_color: null, + version: 1, + can_edit: true, + can_delete: true, + can_recover: false, + can_wiki: true, + read: true, + user_title: "team", + title_is_group: false, + bookmarked: false, + actions_summary: [ + { + id: 2, + count: 2, + acted: true, + can_undo: true, + }, + { + id: 3, + can_act: true, + }, + { + id: 4, + can_act: true, + }, + { + id: 8, + can_act: true, + }, + { + id: 6, + can_act: true, + }, + { + id: 7, + can_act: true, + }, + ], + moderator: false, + admin: false, + staff: false, + user_id: 4, + hidden: false, + trust_level: 0, + deleted_at: null, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + notice: { + type: "returning_user", + last_posted_at: "2020-05-03T22:26:04Z", + }, + reviewable_id: 0, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + reactions: [ + { + id: "heart", + type: "emoji", + count: 2, + }, + ], + current_user_reaction: { + id: "heart", + type: "emoji", + can_undo: true, + }, + reaction_users_count: 2, + current_user_used_main_reaction: true, + }, + { + id: 1079, + name: "David Tylor", + username: "david", + avatar_template: "/user_avatar/localhost/david/{size}/7_2.png", + created_at: "2021-03-10T21:54:50.134Z", + cooked: + '\u003cp\u003eReally liking this plugin \u003cimg src="//localhost:3000/images/emoji/twitter/open_hands.png?v=9" title=":open_hands:" class="emoji" alt=":open_hands:"\u003e\u003c/p\u003e', + post_number: 4, + post_type: 1, + updated_at: "2021-03-10T21:54:50.134Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 0, + reads: 4, + readers_count: 3, + score: 60.8, + yours: false, + topic_id: 374, + topic_slug: "discourse-reactions-beyond-likes", + display_username: "David Tylor", + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_bg_color: null, + primary_group_flair_color: null, + version: 1, + can_edit: true, + can_delete: true, + can_recover: true, + can_wiki: true, + read: true, + user_title: "team", + title_is_group: false, + bookmarked: false, + actions_summary: [ + { id: 3, can_act: true }, + { id: 4, can_act: true }, + { id: 8, can_act: true }, + { id: 6, can_act: true }, + { id: 7, can_act: true }, + ], + moderator: true, + admin: false, + staff: true, + user_id: 2, + hidden: false, + trust_level: 0, + deleted_at: "2021-04-08T07:51:23.620Z", + deleted_by: { + id: 1, + username: "ahmedgagan6", + name: "Ahmed Gagan", + avatar_template: + "/user_avatar/localhost/ahmedgagan6/{size}/4_2.png", + }, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + user_custom_fields: { user_notes_count: "1" }, + reviewable_id: 0, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + reactions: [], + current_user_reaction: null, + reaction_users_count: 0, + current_user_used_main_reaction: false, + }, + ], + stream: [854, 1076, 1078, 1079], + }, + timeline_lookup: [[1, 26]], + suggested_topics: [ + { + id: 24, + title: "Custom Trust Levels", + fancy_title: "Custom Trust Levels", + slug: "custom-trust-levels", + posts_count: 4, + reply_count: 0, + highest_post_number: 8, + image_url: null, + created_at: "2020-04-08T10:56:18.795Z", + last_posted_at: "2020-06-22T09:41:47.879Z", + bumped: true, + bumped_at: "2020-05-07T20:43:02.233Z", + archetype: "regular", + unseen: false, + last_read_post_number: 8, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + notification_level: 3, + bookmarked: false, + liked: false, + tags: [], + like_count: 2, + views: 22, + category_id: 2, + featured_link: null, + posters: [ + { + extras: "latest single", + description: "Original Poster, Most Recent Poster", + user: { + id: 1, + username: "ahmedgagan6", + name: "Ahmed Gagan", + avatar_template: + "/user_avatar/localhost/ahmedgagan6/{size}/4_2.png", + }, + }, + ], + }, + { + id: 74, + title: "This post is to be deleted in a while", + fancy_title: "This post is to be deleted in a while", + slug: "this-post-is-to-be-deleted-in-a-while", + posts_count: 4, + reply_count: 0, + highest_post_number: 5, + image_url: null, + created_at: "2020-06-10T08:17:45.876Z", + last_posted_at: "2020-06-22T09:40:28.704Z", + bumped: true, + bumped_at: "2020-06-10T08:17:46.566Z", + archetype: "regular", + unseen: false, + last_read_post_number: 5, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + notification_level: 3, + bookmarked: false, + liked: false, + tags: [], + like_count: 0, + views: 6, + category_id: 1, + featured_link: null, + posters: [ + { + extras: "latest single", + description: "Original Poster, Most Recent Poster", + user: { + id: 1, + username: "ahmedgagan6", + name: "Ahmed Gagan", + avatar_template: + "/user_avatar/localhost/ahmedgagan6/{size}/4_2.png", + }, + }, + ], + }, + { + id: 100, + title: "topic title22", + fancy_title: "topic title22", + slug: "topic-title22", + posts_count: 2, + reply_count: 0, + highest_post_number: 2, + image_url: null, + created_at: "2020-06-22T10:05:14.702Z", + last_posted_at: "2020-06-22T10:09:04.325Z", + bumped: true, + bumped_at: "2020-06-22T10:05:14.987Z", + archetype: "regular", + unseen: false, + last_read_post_number: 2, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + notification_level: 2, + bookmarked: false, + liked: false, + tags: [], + like_count: 0, + views: 1, + category_id: 1, + featured_link: null, + posters: [ + { + extras: null, + description: "Original Poster", + user: { + id: -1, + username: "system", + name: "system", + avatar_template: "/images/discourse-logo-sketch-small.png", + }, + }, + { + extras: "latest", + description: "Most Recent Poster", + user: { + id: 1, + username: "ahmedgagan6", + name: "Ahmed Gagan", + avatar_template: + "/user_avatar/localhost/ahmedgagan6/{size}/4_2.png", + }, + }, + ], + }, + { + id: 115, + title: "topic title37", + fancy_title: "topic title37", + slug: "topic-title37", + posts_count: 4, + reply_count: 0, + highest_post_number: 4, + image_url: null, + created_at: "2020-06-22T10:05:23.201Z", + last_posted_at: "2020-06-22T12:26:23.978Z", + bumped: true, + bumped_at: "2020-06-22T10:05:23.432Z", + archetype: "regular", + unseen: false, + last_read_post_number: 4, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + notification_level: 3, + bookmarked: false, + liked: false, + tags: [], + like_count: 0, + views: 2, + category_id: 1, + featured_link: null, + posters: [ + { + extras: null, + description: "Original Poster", + user: { + id: -1, + username: "system", + name: "system", + avatar_template: "/images/discourse-logo-sketch-small.png", + }, + }, + { + extras: "latest", + description: "Most Recent Poster", + user: { + id: 1, + username: "ahmedgagan6", + name: "Ahmed Gagan", + avatar_template: + "/user_avatar/localhost/ahmedgagan6/{size}/4_2.png", + }, + }, + ], + }, + { + id: 122, + title: "topic title44", + fancy_title: "topic title44", + slug: "topic-title44", + posts_count: 4, + reply_count: 0, + highest_post_number: 6, + image_url: null, + created_at: "2020-06-22T10:05:27.004Z", + last_posted_at: "2020-06-22T12:24:34.727Z", + bumped: true, + bumped_at: "2020-06-22T10:05:27.269Z", + archetype: "regular", + unseen: false, + last_read_post_number: 6, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + notification_level: 3, + bookmarked: false, + liked: false, + tags: [], + like_count: 0, + views: 4, + category_id: 1, + featured_link: null, + posters: [ + { + extras: null, + description: "Original Poster", + user: { + id: -1, + username: "system", + name: "system", + avatar_template: "/images/discourse-logo-sketch-small.png", + }, + }, + { + extras: "latest", + description: "Most Recent Poster", + user: { + id: 1, + username: "ahmedgagan6", + name: "Ahmed Gagan", + avatar_template: + "/user_avatar/localhost/ahmedgagan6/{size}/4_2.png", + }, + }, + ], + }, + ], + tags: [], + id: 374, + title: "Discourse reactions - Beyond likes", + fancy_title: "Discourse reactions - Beyond likes", + posts_count: 4, + created_at: "2021-03-10T20:35:28.349Z", + views: 36, + reply_count: 0, + like_count: 195, + last_posted_at: "2021-03-10T21:54:50.134Z", + visible: true, + closed: false, + archived: false, + has_summary: false, + archetype: "regular", + slug: "discourse-reactions-beyond-likes", + category_id: 9, + word_count: 514, + deleted_at: null, + user_id: 1, + featured_link: null, + pinned_globally: false, + pinned_at: null, + pinned_until: null, + image_url: null, + slow_mode_seconds: 0, + draft: null, + draft_key: "topic_374", + draft_sequence: 8, + posted: true, + unpinned: null, + pinned: false, + current_post_number: 1, + highest_post_number: 4, + last_read_post_number: 4, + last_read_post_id: 1079, + deleted_by: null, + has_deleted: false, + actions_summary: [ + { + id: 4, + count: 0, + hidden: false, + can_act: true, + }, + { + id: 8, + count: 0, + hidden: false, + can_act: true, + }, + { + id: 7, + count: 0, + hidden: false, + can_act: true, + }, + ], + chunk_size: 20, + bookmarked: false, + topic_timer: null, + message_bus_last_id: 440, + participant_count: 4, + show_read_indicator: false, + thumbnails: null, + valid_reactions: [ + "heart", + "laughing", + "open_mouth", + "cry", + "angry", + "thumbsup", + "thumbsdown", + "nose:t2", + ], + details: { + can_edit: true, + notification_level: 3, + notifications_reason_id: 1, + can_move_posts: true, + can_delete: true, + can_remove_allowed_users: true, + can_invite_to: true, + can_invite_via_email: true, + can_create_post: true, + can_reply_as_new_topic: true, + can_flag_topic: true, + can_convert_topic: true, + can_review_topic: true, + can_close_topic: true, + can_archive_topic: true, + can_split_merge_topic: true, + can_edit_staff_notes: true, + can_toggle_topic_visibility: true, + can_pin_unpin_topic: true, + can_moderate_category: true, + can_remove_self_id: 1, + participants: [ + { + id: 1, + username: "ahmedgagan6", + name: "Ahmed Gagan", + avatar_template: "/user_avatar/localhost/ahmedgagan6/{size}/4_2.png", + post_count: 1, + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_color: null, + primary_group_flair_bg_color: null, + }, + { + id: 2, + username: "david", + name: "David Tylor", + avatar_template: "/user_avatar/localhost/david/{size}/7_2.png", + post_count: 1, + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_color: null, + primary_group_flair_bg_color: null, + }, + { + id: 3, + username: "sam", + name: "Sam Saffron", + avatar_template: "/user_avatar/localhost/sam/{size}/5_2.png", + post_count: 1, + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_color: null, + primary_group_flair_bg_color: null, + }, + { + id: 4, + username: "joffreyjaffeux", + name: "Joffrey Jaffeux", + avatar_template: + "/user_avatar/localhost/joffreyjaffeux/{size}/11_2.png", + post_count: 1, + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_color: null, + primary_group_flair_bg_color: null, + }, + ], + created_by: { + id: 1, + username: "ahmedgagan6", + name: "Ahmed Gagan", + avatar_template: "/user_avatar/localhost/ahmedgagan6/{size}/4_2.png", + }, + last_poster: { + id: 2, + username: "david", + name: "David Tylor", + avatar_template: "/user_avatar/localhost/david/{size}/7_2.png", + }, + }, + }, +}; diff --git a/translator.yml b/translator.yml index e5dfd8b6c28..bf5d7b7041b 100644 --- a/translator.yml +++ b/translator.yml @@ -108,3 +108,10 @@ files: - source_path: themes/horizon/locales/en.yml destination_path: themes/horizon.yml label: theme + + - source_path: plugins/discourse-reactions/config/locales/client.en.yml + destination_path: plugins/discourse-reactions/client.yml + label: discourse-reactions + - source_path: plugins/discourse-reactions/config/locales/server.en.yml + destination_path: plugins/discourse-reactions/server.yml + label: discourse-reactions