discourse/plugins/discourse-post-voting/plugin.rb
chapoi 8e44060201
UX: post voting styling, accessibility, and small fixes (#39443)
This PR started mainly to apply styling changes to remain consistent
with its sister TopicVoting plugin: #39234

Inevitably, this also lead to some refactoring as there was still a lot
legacy in use in this plugin.
In this commit:
- Rewrote styles using BEM conventions, trimming down from 9 > 4
BlockElements, and spacing variables
- Consolidated `common`/`mobile`/`desktop` stylesheets into a single
responsive sheet using `viewport` mixins
- Replaced the custom comments popup with `DMenu`
  - Swapped `angle-up` icons for `vote-up` / `vote-up-filled`
- Updated sort controls to match standard navigation styling pills and
to refresh in place
  - Updated character counter for composer to use standard counter
  - Added a zero-upvotes, to maintain alignment in comments  
- Refined styling for sizing, font-colours, spacing,…
  - Cleaned up class names and wrapper markup
- Fixed missing accessibility by adding aria-labels, titles, and
translation strings for vote buttons, comment actions, and the "show who
voted" toggle

Note: Nothing in the all-the-themes repo customises this, except for one
third-party, which has been notified of the refactor. Unity needs some
class changes (done
[here](https://github.com/discourse/discourse-unity-theme/pull/83)) .


### Desktop
| BC | AC |
|--------|--------|
| <img width="1348" height="1624" alt="CleanShot 2026-04-22 at 11 44
21@2x"
src="https://github.com/user-attachments/assets/608c1e37-72a9-461a-b873-cb494c491a8e"
/> | <img width="1438" height="1614" alt="CleanShot 2026-04-22 at 11 40
39@2x"
src="https://github.com/user-attachments/assets/9b3437c8-c2ad-47cb-a06a-fd259e461455"
/> |
| <img width="1348" height="1624" alt="CleanShot 2026-04-22 at 11 45
27@2x"
src="https://github.com/user-attachments/assets/499cf9f4-4b89-433a-b5ff-517a651b945f"
/> | <img width="1438" height="1610" alt="CleanShot 2026-04-22 at 11 41
30@2x"
src="https://github.com/user-attachments/assets/99e98fa3-0372-4b1b-ba66-5d8b188559b6"
/> |



### Mobile
| BC | AC |
|--------|--------|
| <img width="1179" height="2556" alt="image"
src="https://github.com/user-attachments/assets/94cc8e9c-06c2-42bd-9fca-b8605557452b"
/> | <img width="1179" height="2556" alt="image"
src="https://github.com/user-attachments/assets/5f2d3b65-7c78-422d-b603-a9b1d498c619"
/> |
2026-04-22 14:41:12 +02:00

257 lines
9.2 KiB
Ruby

# frozen_string_literal: true
# name: discourse-post-voting
# about: Allows the creation of topics with votable posts.
# meta_topic_id: 227808
# version: 0.0.1
# authors: Alan Tan
# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-post-voting
register_asset "stylesheets/common/post-voting.scss"
register_asset "stylesheets/common/post-voting-crawler.scss"
enabled_site_setting :post_voting_enabled
module ::PostVoting
PLUGIN_NAME = "discourse-post-voting"
end
after_initialize do
require_relative "lib/post_voting/engine"
require_relative "lib/post_voting/vote_manager"
require_relative "lib/post_voting/guardian_extension"
require_relative "lib/post_voting/comment_creator"
require_relative "lib/post_voting/comment_review_queue"
require_relative "extensions/post_extension"
require_relative "extensions/post_serializer_extension"
require_relative "extensions/topic_extension"
require_relative "extensions/topic_list_item_serializer_extension"
require_relative "extensions/topic_view_serializer_extension"
require_relative "extensions/topic_view_extension"
require_relative "extensions/user_extension"
require_relative "app/validators/post_voting_comment_validator"
require_relative "app/controllers/post_voting/votes_controller"
require_relative "app/controllers/post_voting/comments_controller"
require_relative "app/models/post_voting_vote"
require_relative "app/models/post_voting_comment"
require_relative "app/models/post_voting_comment_custom_field"
require_relative "app/models/reviewable_post_voting_comment"
require_relative "app/serializers/basic_voter_serializer"
require_relative "app/serializers/post_voting_comment_serializer"
require_relative "app/serializers/reviewable_post_voting_comments_serializer"
require_relative "config/routes"
register_svg_icon "vote-up"
register_svg_icon "vote-up-filled"
register_svg_icon "info"
register_post_custom_field_type("vote_history", :json)
register_post_custom_field_type("vote_count", :integer)
register_reviewable_type ReviewablePostVotingComment
if Rails.env.local? && enabled?
require_relative "lib/discourse_dev/reviewable_post_voting_comment"
DiscoursePluginRegistry.discourse_dev_populate_reviewable_types.add DiscourseDev::ReviewablePostVotingComment
end
reloadable_patch do
Post.include(PostVoting::PostExtension)
Topic.include(PostVoting::TopicExtension)
PostSerializer.include(PostVoting::PostSerializerExtension)
TopicView.prepend(PostVoting::TopicViewExtension)
TopicViewSerializer.include(PostVoting::TopicViewSerializerExtension)
TopicListItemSerializer.include(PostVoting::TopicListItemSerializerExtension)
User.include(PostVoting::UserExtension)
Guardian.prepend(PostVoting::GuardianExtension)
end
# TODO: Performance of the query degrades as the number of posts a user has voted
# on increases. We should probably keep a counter cache in the user's
# custom fields.
add_to_class(:user, :vote_count) { Post.where(user_id: self.id).sum(:qa_vote_count) }
add_to_serializer(:user_card, :vote_count) { object.vote_count }
add_to_class(:topic_view, :user_voted_posts) do |user|
@user_voted_posts ||= {}
@user_voted_posts[user.id] ||= begin
PostVotingVote.where(user: user, post: @posts).distinct.pluck(:post_id)
end
end
add_to_class(:topic_view, :user_voted_posts_last_timestamp) do |user|
@user_voted_posts_last_timestamp ||= {}
@user_voted_posts_last_timestamp[user.id] ||= begin
PostVotingVote
.where(user: user, post: @posts)
.group(:votable_id, :created_at)
.pluck(:votable_id, :created_at)
end
end
TopicView.apply_custom_default_scope do |scope, topic_view|
if topic_view.topic.is_post_voting? &&
!topic_view.instance_variable_get(:@replies_to_post_number) &&
!topic_view.instance_variable_get(:@post_ids)
scope = scope.where(reply_to_post_number: nil)
if topic_view.instance_variable_get(:@filter) != TopicView::ACTIVITY_FILTER
scope =
scope
.where.not(post_type: [Post.types[:whisper], Post.types[:small_action]])
.unscope(:order)
.order("CASE post_number WHEN 1 THEN 0 ELSE 1 END, qa_vote_count DESC, post_number ASC")
end
scope
else
scope
end
end
TopicView.on_preload do |topic_view|
next if !topic_view.topic.is_post_voting?
topic_view.comments = {}
topic_view.comments_counts = {}
topic_view.posts_user_voted = {}
topic_view.comments_user_voted = {}
topic_view.posts_voted_on = []
post_ids = topic_view.posts.pluck(:id)
next if post_ids.blank?
post_ids_sql = post_ids.join(",")
comment_ids_sql = <<~SQL
SELECT
post_voting_comments.id
FROM post_voting_comments
INNER JOIN LATERAL (
SELECT 1
FROM (
SELECT
comments.id
FROM post_voting_comments comments
WHERE comments.post_id = post_voting_comments.post_id
AND comments.deleted_at IS NULL
ORDER BY comments.id ASC
LIMIT #{TopicView::PRELOAD_COMMENTS_COUNT}
) X
WHERE X.id = post_voting_comments.id
) Y ON true
WHERE post_voting_comments.post_id IN (#{post_ids_sql})
AND post_voting_comments.deleted_at IS NULL
SQL
PostVotingComment
.includes(:user)
.where("id IN (#{comment_ids_sql})")
.order(id: :asc)
.each do |comment|
topic_view.comments[comment.post_id] ||= []
topic_view.comments[comment.post_id] << comment
end
topic_view.comments_counts = PostVotingComment.where(post_id: post_ids).group(:post_id).count
if topic_view.guardian.user
PostVotingVote
.where(user: topic_view.guardian.user, votable_type: "Post", votable_id: post_ids)
.pluck(:votable_id, :direction)
.each { |post_id, direction| topic_view.posts_user_voted[post_id] = direction }
PostVotingVote
.joins(
"INNER JOIN post_voting_comments comments ON comments.id = post_voting_votes.votable_id",
)
.where(user: topic_view.guardian.user, votable_type: "PostVotingComment")
.where("comments.post_id IN (?)", post_ids)
.pluck(:votable_id)
.each { |votable_id| topic_view.comments_user_voted[votable_id] = true }
end
topic_view.posts_voted_on =
PostVotingVote.where(votable_type: "Post", votable_id: post_ids).distinct.pluck(:votable_id)
end
add_permitted_post_create_param(:create_as_post_voting)
# TODO: Core should be exposing the following as proper plugin interfaces.
NewPostManager.add_plugin_payload_attribute(:subtype)
TopicSubtype.register(Topic::POST_VOTING_SUBTYPE)
NewPostManager.add_handler do |manager|
if !manager.args[:topic_id] && manager.args[:create_as_post_voting].to_s == "true" &&
(manager.args[:archetype].blank? || manager.args[:archetype] == Archetype.default)
manager.args[:subtype] = Topic::POST_VOTING_SUBTYPE
end
false
end
register_modifier(:topic_embed_import_create_args) do |args|
category_id = args[:category]
next args unless category_id
next args if args[:archetype] != Archetype.default && args[:archetype].present?
category = Category.find_by(id: category_id)
if category&.create_as_post_voting_default || category&.only_post_voting_in_this_category
args[:subtype] = Topic::POST_VOTING_SUBTYPE
end
args
end
register_category_custom_field_type(PostVoting::CREATE_AS_POST_VOTING_DEFAULT, :boolean)
register_preloaded_category_custom_fields(PostVoting::CREATE_AS_POST_VOTING_DEFAULT)
add_to_class(:category, :create_as_post_voting_default) do
ActiveModel::Type::Boolean.new.cast(
self.custom_fields[PostVoting::CREATE_AS_POST_VOTING_DEFAULT],
)
end
add_to_serializer(:basic_category, :create_as_post_voting_default) do
object.create_as_post_voting_default
end
add_to_serializer(:current_user, :can_flag_post_voting_comments) do
scope.can_flag_post_voting_comments?
end
register_category_custom_field_type(PostVoting::ONLY_POST_VOTING_IN_THIS_CATEGORY, :boolean)
register_preloaded_category_custom_fields PostVoting::ONLY_POST_VOTING_IN_THIS_CATEGORY
register_preloaded_category_custom_fields PostVoting::CREATE_AS_POST_VOTING_DEFAULT
add_to_class(:category, :only_post_voting_in_this_category) do
ActiveModel::Type::Boolean.new.cast(
self.custom_fields[PostVoting::ONLY_POST_VOTING_IN_THIS_CATEGORY],
)
end
add_to_serializer(:basic_category, :only_post_voting_in_this_category) do
object.only_post_voting_in_this_category
end
add_model_callback(:post, :before_create) do
if SiteSetting.post_voting_enabled && self.is_post_voting_topic? && self.via_email &&
self.reply_to_post_number == 1
self.reply_to_post_number = nil
end
end
register_user_destroyer_on_content_deletion_callback(
Proc.new do |user|
post_voting_comment_ids = PostVotingComment.where(user_id: user.id).pluck(:id)
PostVotingComment.where(id: post_voting_comment_ids).delete_all
ReviewablePostVotingComment.where(
target_id: post_voting_comment_ids,
target_type: "PostVotingComment",
).delete_all
PostVoting::VoteManager.bulk_remove_votes_by(user)
end,
)
end