diff --git a/app/assets/javascripts/initializers/initializeFetchFollowedArticles.js.erb b/app/assets/javascripts/initializers/initializeFetchFollowedArticles.js.erb index 2692ac66e..205566a87 100644 --- a/app/assets/javascripts/initializers/initializeFetchFollowedArticles.js.erb +++ b/app/assets/javascripts/initializers/initializeFetchFollowedArticles.js.erb @@ -33,6 +33,7 @@ function insertNewArticles(user){ var containsOrganizationID = findOne([article.organization_id], user.followed_organization_ids || []) var intersectedTags = intersect_arrays(user.followed_tag_names, article.cached_tag_list_array) var followedPoints = 1 + var experienceDifference = Math.abs(article['experience_level_rating'] - user.experience_level) JSON.parse(user.followed_tags).map(function(tag) { if (intersectedTags.includes(tag.name)) { followedPoints = followedPoints + tag.points @@ -51,6 +52,7 @@ function insertNewArticles(user){ } else if (rand < 0.6) { articlePoints = articlePoints + 2 } + articlePoints = articlePoints - (experienceDifference/2); article['points'] = articlePoints }); var sortedArticles = articlesJSON.sort(function(a, b) { @@ -70,12 +72,13 @@ function insertTopArticles(user){ var articlesJSON = JSON.parse(el.dataset.articles) var insertPlace = document.getElementById("article-index-hidden-div"); if (insertPlace) { - articlesJSON.forEach(function(article){ + articlesJSON.forEach(function(article){ var articlePoints = 0 var containsUserID = findOne([article.user_id], user.followed_user_ids || []) var containsOrganizationID = findOne([article.organization_id], user.followed_organization_ids || []) var intersectedTags = intersect_arrays(user.followed_tag_names, article.cached_tag_list_array) var followedPoints = 1 + var experienceDifference = Math.abs(article['experience_level_rating'] - user.experience_level) JSON.parse(user.followed_tags).map(function(tag) { if (intersectedTags.includes(tag.name)) { followedPoints = followedPoints + tag.points @@ -94,6 +97,7 @@ function insertTopArticles(user){ } else if (rand < 0.6) { articlePoints = articlePoints + 2 } + articlePoints = articlePoints - (experienceDifference/2); article['points'] = articlePoints }); var sortedArticles = articlesJSON.sort(function(a, b) { diff --git a/app/controllers/async_info_controller.rb b/app/controllers/async_info_controller.rb index e6975e2b0..b26c8c5c7 100644 --- a/app/controllers/async_info_controller.rb +++ b/app/controllers/async_info_controller.rb @@ -46,7 +46,8 @@ class AsyncInfoController < ApplicationController checked_code_of_conduct: @user.checked_code_of_conduct, number_of_comments: @user.comments.count, display_sponsors: @user.display_sponsors, - trusted: @user.trusted + trusted: @user.trusted, + experience_level: @user.experience_level } end end diff --git a/app/controllers/moderations_controller.rb b/app/controllers/moderations_controller.rb index 222a8b72b..55065eb75 100644 --- a/app/controllers/moderations_controller.rb +++ b/app/controllers/moderations_controller.rb @@ -1,6 +1,20 @@ class ModerationsController < ApplicationController after_action :verify_authorized + def index + authorize(User, :moderation_routes?) + @articles = Article.where(published: true). + includes(:rating_votes). + where("rating_votes_count < 3"). + where("score > -5"). + order("featured_number DESC").limit(50) + if params[:tag].present? + @articles = @articles. + cached_tagged_with(params[:tag]) + end + @articles = @articles.decorate + end + def article authorize(User, :moderation_routes?) @moderatable = Article.find_by_slug(params[:slug]) diff --git a/app/controllers/rating_votes_controller.rb b/app/controllers/rating_votes_controller.rb new file mode 100644 index 000000000..29581fdfd --- /dev/null +++ b/app/controllers/rating_votes_controller.rb @@ -0,0 +1,24 @@ +class RatingVotesController < ApplicationController + after_action :verify_authorized + + def create + authorize RatingVote + rating_vote = RatingVote.where(user_id: current_user.id, article_id: rating_vote_params[:article_id]).first || RatingVote.new + rating_vote.user_id = current_user.id + rating_vote.article_id = rating_vote_params[:article_id] + rating_vote.rating = rating_vote_params[:rating].to_f + rating_vote.group = rating_vote_params[:group] + if rating_vote.save + rating_vote.assign_article_rating + redirect_back(fallback_location: "/mod") + else + render json: { result: "Not Upserted Successfully" } + end + end + + private + + def rating_vote_params + params.require(:rating_vote).permit(policy(RatingVote).permitted_attributes) + end +end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 052f72c32..90155207b 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -106,7 +106,7 @@ class StoriesController < ApplicationController def handle_base_index @home_page = true @page = (params[:page] || 1).to_i - num_articles = 25 + num_articles = 30 @stories = article_finder(num_articles) add_param_context(:page, :timeframe) if ["week", "month", "year", "infinity"].include?(params[:timeframe]) diff --git a/app/models/article.rb b/app/models/article.rb index 16bc48af2..7da085189 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -17,7 +17,8 @@ class Article < ApplicationRecord belongs_to :collection, optional: true has_many :comments, as: :commentable has_many :buffer_updates - has_many :notifications, as: :notifiable + has_many :notifications, as: :notifiable + has_many :rating_votes validates :slug, presence: { if: :published? }, format: /\A[0-9a-z-]*\z/, uniqueness: { scope: :user_id } @@ -76,6 +77,7 @@ class Article < ApplicationRecord :main_image, :main_image_background_hex_color, :updated_at, :slug, :video, :user_id, :organization_id, :video_source_url, :video_code, :video_thumbnail_url, :video_closed_caption_track_url, + :experience_level_rating, :experience_level_rating_distribution, :published_at, :crossposted_at, :boost_states, :description, :reading_time, :video_duration_in_seconds) } @@ -137,8 +139,8 @@ class Article < ApplicationRecord per_environment: true, enqueue: :trigger_delayed_index do attributes :title, :path, :class_name, :comments_count, :reading_time, - :tag_list, :positive_reactions_count, :id, :hotness_score, :score, :readable_publish_date, - :flare_tag, :user_id, :organization_id, :cloudinary_video_url, :video_duration_in_minutes + :tag_list, :positive_reactions_count, :id, :hotness_score, :score, :readable_publish_date, :flare_tag, :user_id, + :organization_id, :cloudinary_video_url, :video_duration_in_minutes, :experience_level_rating, :experience_level_rating_distribution attribute :published_at_int do published_at.to_i end diff --git a/app/models/rating_vote.rb b/app/models/rating_vote.rb new file mode 100644 index 000000000..2d418b98c --- /dev/null +++ b/app/models/rating_vote.rb @@ -0,0 +1,27 @@ +class RatingVote < ApplicationRecord + belongs_to :article + belongs_to :user + + validates_uniqueness_of :user_id, scope: :article_id + validates :group, inclusion: { in: %w(experience_level) } + validates :rating, numericality: { greater_than: 0.0, less_than_or_equal_to: 10.0 } + validate :permissions + counter_culture :article + counter_culture :user + + def assign_article_rating + ratings = article.rating_votes.where(group: group).pluck(:rating) + average = ratings.sum / ratings.size + article.update_column(:experience_level_rating, average) + article.update_column(:experience_level_rating_distribution, ratings.sort.max - ratings.sort.min) + article.update_column(:last_experience_level_rating_at, Time.current) + end + + private + + def permissions + unless user&.trusted + errors.add(:user_id, "is not permitted to take this action.") + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 975ed2933..06bac2300 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -35,6 +35,7 @@ class User < ApplicationRecord has_many :chat_channels, through: :chat_channel_memberships has_many :push_notification_subscriptions, dependent: :destroy has_many :feedback_messages + has_many :rating_votes has_many :html_variants, dependent: :destroy has_many :mentor_relationships_as_mentee, class_name: "MentorRelationship", foreign_key: "mentee_id" @@ -65,6 +66,7 @@ class User < ApplicationRecord exclusion: { in: ReservedWords.all, message: "username is reserved" } validates :twitter_username, uniqueness: { allow_blank: true } validates :github_username, uniqueness: { allow_blank: true } + validates :experience_level, numericality: { less_than_or_equal_to: 10 }, allow_blank: true validates :text_color_hex, format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true validates :bg_color_hex, format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true validates :website_url, :employer_url, :mastodon_url, diff --git a/app/policies/rating_vote_policy.rb b/app/policies/rating_vote_policy.rb new file mode 100644 index 000000000..53b7d435d --- /dev/null +++ b/app/policies/rating_vote_policy.rb @@ -0,0 +1,9 @@ +class RatingVotePolicy < ApplicationPolicy + def create? + !user_is_banned? + end + + def permitted_attributes + %i[rating group article_id] + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 1ed31728f..2c7b6b200 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -73,6 +73,7 @@ class UserPolicy < ApplicationPolicy employer_name employer_url employment_title + experience_level facebook_url feed_admin_publish_permission feed_mark_canonical diff --git a/app/views/moderations/index.html.erb b/app/views/moderations/index.html.erb new file mode 100644 index 000000000..3394c47c4 --- /dev/null +++ b/app/views/moderations/index.html.erb @@ -0,0 +1,62 @@ + + +



+
+

Mod Convenience Dashboard

+

Add "Experience Level" ratings

+
More mod tools will be added as they are created.
+
+
+ <% @articles.each do |article| %> + <% @rating_vote = article.rating_votes.where(user_id: current_user.id).first %> + <% unless @rating_vote %> +
+
+

<%= article.title %>

+ <% cache("main-article-tags-#{article.cached_tag_list}", expires_in: 30.hours) do %> +
+ <% article.cached_tag_list_array.each do |tag| %> + #<%= tag %> + <% end %> +
+ <% end %> +
+
+ <%= HTML_Truncator.truncate(article.processed_html, + 200, ellipsis: '... Read Entire Post').html_safe %> +
+
+

Moderate

+

Experience Level Target:

+ <%= form_for(RatingVote.new) do |f| %> + <%= f.hidden_field :article_id, value: article.id %> + <%= f.hidden_field :group, value: "experience_level" %> + + + + + + + + + + + <% end %> +

+
+
+ <% end %> + <% end %> +
diff --git a/app/views/moderations/mod.html.erb b/app/views/moderations/mod.html.erb index 6c7dd35cb..d3e04b52d 100644 --- a/app/views/moderations/mod.html.erb +++ b/app/views/moderations/mod.html.erb @@ -55,6 +55,17 @@ height: 50px; border: 1px solid #888; } + .level-rating-button { + border-radius: 8px; + font-size: 1.1em; + padding: 3px; + width: 42px; + border: 2px solid black; + font-weight: bold; + } + .level-rating-button.selected { + background: #9bebff; + }
@@ -74,7 +85,7 @@

ADMIN: Comment Admin | User Admin

<% else %>

- All negative reactions are 100% private. + All negative reactions are private.

Use the thumbsdown(👎) to move something "down" for any reason (quality, usefulness, code of conduct grey area).

The vomit(🤢) is for code of conduct violations (harassment, being an asshole, spam, etc.).

The DEV team will be notified about vomits and take appropriate action, but leaving a level-headed comment addressing the behavior is welcome if you feel up for it. @@ -110,6 +121,31 @@ <% end %>

<% end %> + <% if @moderatable.class.name == "Article" %> + <% @rating_vote = RatingVote.where(article_id: @moderatable.id, user_id: current_user.id).first %> + <%= form_for(RatingVote.new) do |f| %> +

Experience Level of Post

+ <%= f.hidden_field :article_id, value: @moderatable.id %> + <%= f.hidden_field :group, value: "experience_level" %> + + + + + + + + + + + <% end %> +

+ All rating votes are private. +

Provide a rating between 1 and 10 based on which audience would find this post most valuable. +

1: for brand new developers, 3: for junior developers, 10: for very advanced developers, etc. +

This will provide approximate and gentle algorithmic adjustments to help deliver relevant content. +

It will be used as one of many indicators. No one rating will have an overly dramatic affect. +

+ <% end %>