memoize story/comment score/flags more efficiently

This commit is contained in:
Peter Bhat Harkins 2020-05-11 21:52:05 -05:00
parent 30801f395a
commit 20c1590753
34 changed files with 436 additions and 282 deletions

View File

@ -9,14 +9,14 @@ var _Lobsters = Class.extend({
storyFlagReasons: { <%= Vote::STORY_REASONS.map{|k,v|
"#{k.inspect}: #{v.inspect}" }.join(", ") %> },
commentDownvoteReasons: { <%= Vote::COMMENT_REASONS.map{|k,v|
commentFlagReasons: { <%= Vote::COMMENT_REASONS.map{|k,v|
"#{k.inspect}: #{v.inspect}" }.join(", ") %> },
upvoteStory: function(voterEl) {
Lobsters.vote("story", voterEl, 1);
},
flagStory: function(voterEl) {
Lobsters._showDownvoteWhyAt("story", voterEl, function(k) {
Lobsters._showFlagWhyAt("story", voterEl, function(k) {
Lobsters.vote("story", voterEl, -1, k); });
},
hideStory: function(hiderEl) {
@ -63,38 +63,38 @@ var _Lobsters = Class.extend({
upvoteComment: function(voterEl) {
Lobsters.vote("comment", voterEl, 1);
},
downvoteComment: function(voterEl) {
Lobsters._showDownvoteWhyAt("comment", voterEl, function(k) {
flagComment: function(voterEl) {
Lobsters._showFlagWhyAt("comment", voterEl, function(k) {
Lobsters.vote("comment", voterEl, -1, k); });
},
_showDownvoteWhyAt: function(thingType, voterEl, onChooseWhy) {
_showFlagWhyAt: function(thingType, voterEl, onChooseWhy) {
if (!Lobsters.curUser)
return Lobsters.bounceToLogin();
var li = $(voterEl).closest(".story, .comment");
if (li.hasClass("downvoted")) {
if (li.hasClass("flagged")) {
/* already upvoted, neutralize */
Lobsters.vote(thingType, voterEl, -1, null);
return;
}
if ($("#downvote_why"))
$("#downvote_why").remove();
if ($("#downvote_why_shadow"))
$("#downvote_why_shadow").remove();
if ($("#flag_why"))
$("#flag_why").remove();
if ($("#flag_why_shadow"))
$("#flag_why_shadow").remove();
var sh = $("<div id=\"downvote_why_shadow\"></div>");
var sh = $("<div id=\"flag_why_shadow\"></div>");
$(voterEl).after(sh);
sh.click(function() {
$("#downvote_why_shadow").remove();
$("#downvote_why").remove();
$("#flag_why_shadow").remove();
$("#flag_why").remove();
});
var d = $("<div id=\"downvote_why\"></div>");
var d = $("<div id=\"flag_why\"></div>");
var reasons;
if (thingType == "comment")
reasons = Lobsters.commentDownvoteReasons;
reasons = Lobsters.commentFlagReasons;
else
reasons = Lobsters.storyFlagReasons;
@ -103,8 +103,8 @@ var _Lobsters = Class.extend({
">" + v + "</a>");
a.click(function() {
$("#downvote_why").remove();
$("#downvote_why_shadow").remove();
$("#flag_why").remove();
$("#flag_why_shadow").remove();
if (k != "")
onChooseWhy(k);
@ -126,7 +126,7 @@ var _Lobsters = Class.extend({
});
d.css("left", $(voterEl).position().left);
} else {
// place downvote menu outside of the comment to avoid inheriting opacity
// place flag menu outside of the comment to avoid inheriting opacity
var voterPos = $(voterEl).position();
d.appendTo($(voterEl).closest(".comments_subtree"));
d.css({
@ -156,18 +156,18 @@ var _Lobsters = Class.extend({
score--;
action = "unvote";
}
else if (li.hasClass("downvoted") && point < 0) {
/* already downvoted, neutralize */
li.removeClass("downvoted");
else if (li.hasClass("flagged") && point < 0) {
/* already flagged, neutralize */
li.removeClass("flagged");
score++;
action = "unvote";
}
else if (point > 0) {
if (li.hasClass("downvoted"))
if (li.hasClass("flagged"))
/* flip flop */
score++;
li.removeClass("downvoted").addClass("upvoted");
li.removeClass("flagged").addClass("upvoted");
score++;
action = "upvote";
}
@ -176,10 +176,10 @@ var _Lobsters = Class.extend({
/* flip flop */
score--;
li.removeClass("upvoted").addClass("downvoted");
li.removeClass("upvoted").addClass("flagged");
showScore = false;
score--;
action = "downvote";
action = "flag";
}
if (showScore)
@ -193,10 +193,10 @@ var _Lobsters = Class.extend({
if (action == "unvote" && thingType == "story" && point < 0)
li.find(".flagger").text("flag");
}
else if (action == "downvote" && thingType == "comment")
else if (action == "flag" && thingType == "comment")
li.find(".reason").html("| " +
Lobsters.commentDownvoteReasons[reason].toLowerCase());
else if (action == "downvote" && thingType == "story")
Lobsters.commentFlagReasons[reason].toLowerCase());
else if (action == "flag" && thingType == "story")
li.find(".flagger").text("unflag");
$.post("/" + (thingType == "story" ? "stories" : thingType + "s") + "/" +
@ -389,8 +389,8 @@ var Lobsters = new _Lobsters();
$(document).ready(function() {
var $olcomments = $("ol.comments");
$olcomments.on("click", ".comment a.downvoter", function() {
Lobsters.downvoteComment(this);
$olcomments.on("click", ".comment a.flagger", function() {
Lobsters.flagComment(this);
return false;
});
$olcomments.on("click", ".comment a.upvoter", function() {

View File

@ -401,7 +401,7 @@ li.story div.voters div.score {
}
div.voters .upvoter,
div.voters .downvoter {
div.voters .flagger {
border-color: transparent transparent #bbb transparent;
border-style: solid;
border-width: 6px;
@ -423,7 +423,7 @@ div.voters .upvoter {
border-bottom-width: 11px;
}
div.voters .downvoter {
div.voters .flagger {
border-color: #bbb transparent transparent transparent;
border-width: 5px;
margin-top: 4px;
@ -431,12 +431,12 @@ div.voters .downvoter {
margin-bottom: -5px;
border-top-width: 9px;
}
div.voters .downvoter:hover,
.downvoted div.voters .downvoter {
div.voters .flagger:hover,
.flagged div.voters .flagger {
border-top-color: gray;
}
div.voters .downvoter.downvoter_stub {
div.voters .flagger.flagger_stub {
border-color: transparent;
}
@ -585,7 +585,7 @@ li .comment_parent_tree_line {
li .comment_parent_tree_line.score_shown {
top: 44px;
}
li .comment_parent_tree_line.can_downvote {
li .comment_parent_tree_line.can_flag {
top: 56px;
}
li .comment_parent_tree_line.no_children {
@ -799,14 +799,14 @@ div.comment_text code {
}
#downvote_why {
#flag_why {
position: absolute;
width: 100px;
border: 1px solid #ccc;
border-bottom: 0;
z-index: 15;
}
#downvote_why a {
#flag_why a {
background-color: white;
color: #555;
border-bottom: 1px solid #ccc;
@ -814,15 +814,15 @@ div.comment_text code {
padding: 3px;
font-size: 9pt;
}
#downvote_why a:hover {
#flag_why a:hover {
background-color: #eee;
}
#downvote_why a.cancelreason {
#flag_why a.cancelreason {
background-color: #eee;
font-size: 8pt;
}
#downvote_why_shadow {
#flag_why_shadow {
position: fixed;
width: 100%;
height: 100%;
@ -1155,7 +1155,6 @@ table.data pre {
div.flash-error,
div.flash-notice,
div.flash-success,
div.downvoteWarning,
div.errorExplanation {
position: relative;
padding: 7px 15px;

View File

@ -106,7 +106,7 @@
}
div.voters a.upvoter,
div.voters a.downvoter {
div.voters a.flagger {
margin-left: 6px;
border-width: 9px;
}

View File

@ -51,7 +51,7 @@ class ApplicationController < ActionController::Base
def flag_warning
@flag_warning_int = time_interval('1m')
@show_flag_warning = (
@user && !!DownvotedCommenters.new(@flag_warning_int[:param], 1.day).check_list_for(@user)
@user && !!FlaggedCommenters.new(@flag_warning_int[:param], 1.day).check_list_for(@user)
)
end

View File

@ -4,7 +4,7 @@ class CommentsController < ApplicationController
caches_page :index, :threads, if: CACHE_PAGE
before_action :require_logged_in_user_or_400,
:only => [:create, :preview, :upvote, :downvote, :unvote]
:only => [:create, :preview, :upvote, :flag, :unvote]
before_action :require_logged_in_user, :only => [:upvoted]
before_action :flag_warning, only: [:threads]
@ -58,7 +58,7 @@ class CommentsController < ApplicationController
redirect_to comment.path
end
else
comment.upvotes = 1
comment.score = 1
comment.current_vote = { :vote => 1 }
preview comment
@ -201,7 +201,7 @@ class CommentsController < ApplicationController
render :plain => "ok"
end
def downvote
def flag
if !(comment = find_comment) || comment.is_gone?
return render :plain => "can't find comment", :status => 400
end
@ -210,8 +210,8 @@ class CommentsController < ApplicationController
return render :plain => "invalid reason", :status => 400
end
if !@user.can_downvote?(comment)
return render :plain => "not permitted to downvote", :status => 400
if !@user.can_flag?(comment)
return render :plain => "not permitted to flag", :status => 400
end
Vote.vote_thusly_on_story_or_comment_for_user_because(

View File

@ -15,16 +15,16 @@ class ModController < ApplicationController
.order('moderations.id desc')
end
def flagged
def flagged_stories
@title = "Flagged Stories"
@stories = period(Story.includes(:tags).unmerged
.includes(:user, :tags)
.where("downvotes > 1")
.where("flags > 1")
.order("stories.id DESC"))
end
def downvoted
@title = "Downvoted Comments"
def flagged_comments
@title = "Flagged Comments"
@comments = period(Comment
.eager_load(:user, :hat, :story => :user, :votes => :user)
.where("(select count(*) from votes where
@ -35,11 +35,11 @@ class ModController < ApplicationController
end
def commenters
@title = "Downvoted Commenters"
dvc = DownvotedCommenters.new(params[:period])
@interval = dvc.interval
@agg = dvc.aggregates
@commenters = dvc.commenters
@title = "Flagged Commenters"
fc = FlaggedCommenters.new(params[:period])
@interval = fc.interval
@agg = fc.aggregates
@commenters = fc.commenters
end
private

View File

@ -2,7 +2,7 @@ class StoriesController < ApplicationController
caches_page :show, if: CACHE_PAGE
before_action :require_logged_in_user_or_400,
:only => [:upvote, :downvote, :unvote, :hide, :unhide, :preview, :save, :unsave]
:only => [:upvote, :flag, :unvote, :hide, :unhide, :preview, :save, :unsave]
before_action :require_logged_in_user,
:only => [:destroy, :create, :edit, :fetch_url_attributes, :new, :suggest]
before_action :verify_user_can_submit_stories, :only => [:new, :create]
@ -106,7 +106,7 @@ class StoriesController < ApplicationController
@story.previewing = true
@story.vote = Vote.new(:vote => 1)
@story.upvotes = 1
@story.score = 1
@story.valid?
@ -284,7 +284,7 @@ class StoriesController < ApplicationController
render :plain => "ok"
end
def downvote
def flag
if !(story = find_story) || story.is_gone?
return render :plain => "can't find story", :status => 400
end
@ -293,8 +293,8 @@ class StoriesController < ApplicationController
return render :plain => "invalid reason", :status => 400
end
if !@user.can_downvote?(story)
return render :plain => "not permitted to downvote", :status => 400
if !@user.can_flag?(story)
return render :plain => "not permitted to flag", :status => 400
end
Vote.vote_thusly_on_story_or_comment_for_user_because(

View File

@ -120,16 +120,16 @@ class UsersController < ApplicationController
int = @flag_warning_int
@showing_user = User.where(username: params[:username]).first!
dc = DownvotedCommenters.new(int[:param], 1.day)
@dc_flagged = dc.commenters.map {|_, c| c[:n_downvotes] }.sort
@flagged_user_stats = dc.check_list_for(@showing_user)
fc = FlaggedCommenters.new(int[:param], 1.day)
@fc_flagged = fc.commenters.map {|_, c| c[:n_flags] }.sort
@flagged_user_stats = fc.check_list_for(@showing_user)
rows = ActiveRecord::Base.connection.exec_query("
select
n_flags, count(*) as n_users
from (
select
comments.user_id, sum(downvotes) n_flags
comments.user_id, sum(flags) as n_flags
from
comments
where
@ -138,13 +138,13 @@ class UsersController < ApplicationController
group by 1
order by 1 asc;
").rows
users = Array.new(@dc_flagged.last.to_i + 1, 0)
users = Array.new(@fc_flagged.last.to_i + 1, 0)
rows.each {|r| users[r.first] = r.last }
@lookup = rows.to_h
@flagged_comments = @showing_user.comments
.where("
comments.downvotes > 0 and
comments.flags > 0 and
comments.created_at >= now() - interval #{int[:dur]} #{int[:intv]}")
.order("id DESC")
.includes(:user, :hat, :story => :user)

View File

@ -21,7 +21,7 @@ class Comment < ApplicationRecord
attr_accessor :current_vote, :previewing, :indent_level
before_validation :on => :create do
self.assign_short_id_and_upvote
self.assign_short_id_and_score
self.assign_initial_confidence
self.assign_thread_id
end
@ -42,11 +42,11 @@ class Comment < ApplicationRecord
) : where('true')
}
DOWNVOTABLE_DAYS = 7
DELETEABLE_DAYS = DOWNVOTABLE_DAYS * 2
FLAGGABLE_DAYS = 7
DELETEABLE_DAYS = FLAGGABLE_DAYS * 2
# the lowest a score can go
DOWNVOTABLE_MIN_SCORE = -10
FLAGGABLE_MIN_SCORE = -10
# the score at which a comment should be collapsed
COLLAPSE_SCORE = -5
@ -88,7 +88,7 @@ class Comment < ApplicationRecord
def self.arrange_for_user(user)
parents = self.order(
Arel.sql("(comments.upvotes - comments.downvotes) < 0 ASC, comments.confidence DESC")
Arel.sql("score < 0 ASC, comments.confidence DESC")
)
.group_by(&:parent_comment_id)
@ -143,11 +143,6 @@ class Comment < ApplicationRecord
nil
end
def self.score_sql
Arel.sql("(CAST(upvotes AS #{Story.votes_cast_type}) - " <<
"CAST(downvotes AS #{Story.votes_cast_type}))")
end
def as_json(_options = {})
h = [
:short_id,
@ -157,8 +152,7 @@ class Comment < ApplicationRecord
:is_deleted,
:is_moderated,
:score,
:upvotes,
:downvotes,
:flags,
{ :comment => (self.is_gone? ? "<em>#{self.gone_text}</em>" : :markeddown_comment) },
:url,
:indent_level,
@ -185,9 +179,9 @@ class Comment < ApplicationRecord
self.confidence = self.calculated_confidence
end
def assign_short_id_and_upvote
def assign_short_id_and_score
self.short_id = ShortId.new(self.class).generate
self.upvotes = 1
self.score ||= 1 # tests are allowed to fake out the score
end
def assign_thread_id
@ -201,11 +195,10 @@ class Comment < ApplicationRecord
# http://evanmiller.org/how-not-to-sort-by-average-rating.html
# https://github.com/reddit/reddit/blob/master/r2/r2/lib/db/_sorts.pyx
def calculated_confidence
n = (upvotes + downvotes).to_f
if n == 0.0
return 0
end
n = (self.score + self.flags * 2).to_f
return 0 if n == 0.0
upvotes = self.score + self.flags
z = 1.281551565545 # 80% confidence
p = upvotes.to_f / n
@ -318,15 +311,18 @@ class Comment < ApplicationRecord
Markdowner.to_html(self.comment)
end
def give_upvote_or_downvote_and_recalculate!(upvote, downvote)
self.upvotes += upvote.to_i
self.downvotes += downvote.to_i
Comment.connection.execute("UPDATE #{Comment.table_name} SET " <<
"upvotes = COALESCE(upvotes, 0) + #{upvote.to_i}, " <<
"downvotes = COALESCE(downvotes, 0) + #{downvote.to_i}, " <<
"confidence = '#{self.calculated_confidence}' WHERE id = #{self.id}")
# TODO: race condition: if two votes arrive at the same time, the second one
# won't take the first's score change into effect for calculated_confidence
def update_score_and_recalculate!(score_delta, flag_delta)
self.score += score_delta
self.flags += flag_delta
Comment.connection.execute <<~SQL
UPDATE comments SET
score = (select sum(vote) from votes where comment_id = comments.id),
flags = (select count(*) from votes where comment_id = comments.id and vote = -1),
confidence = #{self.calculated_confidence}
WHERE id = #{self.id.to_i}
SQL
self.story.recalculate_hotness!
end
@ -374,9 +370,9 @@ class Comment < ApplicationRecord
user && user.id == self.user_id && self.created_at && self.created_at < DELETEABLE_DAYS.days.ago
end
def is_downvotable?
if self.created_at && self.score > DOWNVOTABLE_MIN_SCORE
Time.current - self.created_at <= DOWNVOTABLE_DAYS.days
def is_flaggable?
if self.created_at && self.score > FLAGGABLE_MIN_SCORE
Time.current - self.created_at <= FLAGGABLE_DAYS.days
else
false
end
@ -450,14 +446,10 @@ class Comment < ApplicationRecord
self.story.update_comments_count!
end
def score
self.upvotes - self.downvotes
end
def score_for_user(u)
if self.show_score_to_user?(u)
score
elsif u && u.can_downvote?(self)
elsif u && u.can_flag?(self)
"~"
else
"&nbsp;".html_safe

View File

@ -1,7 +1,7 @@
# Finds the consistent most-heavily-downvoted commenters. Requires downvotes to be spread over
# Finds the consistent most-heavily-flagged commenters. Requires flags to be spread over
# several comments and stories because anyone can have a bad thread or a bad day.
class DownvotedCommenters
class FlaggedCommenters
include IntervalHelper
attr_reader :interval, :period, :cache_time
@ -17,19 +17,19 @@ class DownvotedCommenters
commenters[showing_user.id]
end
# aggregates for all commenters; not just those receiving downvotes
# aggregates for all commenters; not just those receiving flags
def aggregates
Rails.cache.fetch("aggregates_#{interval}_#{cache_time}", expires_in: self.cache_time) {
ActiveRecord::Base.connection.exec_query("
select
stddev(sum_downvotes) as stddev,
sum(sum_downvotes) as sum,
avg(sum_downvotes) as avg,
stddev(sum_flags) as stddev,
sum(sum_flags) as sum,
avg(sum_flags) as avg,
avg(n_comments) as n_comments,
count(*) as n_commenters
from (
select
sum(downvotes) as sum_downvotes,
sum(flags) as sum_flags,
count(*) as n_comments
from comments join users on comments.user_id = users.id
where
@ -42,16 +42,16 @@ class DownvotedCommenters
}
end
def stddev_sum_downvotes
def stddev_sum_flags
aggregates[:stddev].to_i
end
def avg_sum_downvotes
def avg_sum_flags
aggregates[:avg].to_i
end
def commenters
Rails.cache.fetch("downvoted_commenters_#{interval}_#{cache_time}",
Rails.cache.fetch("flagged_commenters_#{interval}_#{cache_time}",
expires_in: self.cache_time) {
rank = 0
User.active.joins(:comments)
@ -59,16 +59,16 @@ class DownvotedCommenters
.group("comments.user_id")
.select("
users.id, users.username,
(sum(downvotes) - #{avg_sum_downvotes})/#{stddev_sum_downvotes} as sigma,
count(distinct if(downvotes > 0, comments.id, null)) as n_comments,
count(distinct if(downvotes > 0, story_id, null)) as n_stories,
sum(downvotes) as n_downvotes,
sum(downvotes)/count(distinct comments.id) as average_downvotes,
(sum(flags) - #{avg_sum_flags})/#{stddev_sum_flags} as sigma,
count(distinct if(flags > 0, comments.id, null)) as n_comments,
count(distinct if(flags > 0, story_id, null)) as n_stories,
sum(flags) as n_flags,
sum(flags)/count(distinct comments.id) as average_flags,
(
count(distinct if(downvotes>0, comments.id, null)) /
count(distinct if(flags > 0, comments.id, null)) /
count(distinct comments.id)
) * 100 as percent_downvoted")
.having("n_comments > 4 and n_stories > 1 and n_downvotes >= 10 and percent_downvoted > 10")
) * 100 as percent_flagged")
.having("n_comments > 4 and n_stories > 1 and n_flags >= 10 and percent_flagged > 10")
.order("sigma desc")
.limit(30)
.each_with_object({}) {|u, hash|
@ -78,10 +78,10 @@ class DownvotedCommenters
sigma: u.sigma,
n_comments: u.n_comments,
n_stories: u.n_stories,
n_downvotes: u.n_downvotes,
average_downvotes: u.average_downvotes,
n_flags: u.n_flags,
average_flags: u.average_flags,
stddev: 0,
percent_downvoted: u.percent_downvoted,
percent_flagged: u.percent_flagged,
}
}
}

View File

@ -149,7 +149,7 @@ class Search
when "newest"
self.results.order!("stories.created_at DESC")
when "points"
self.results.order!("#{Story.score_sql} DESC")
self.results.order!("score DESC")
end
when "comments"
@ -175,7 +175,7 @@ class Search
when "newest"
self.results.order!("created_at DESC")
when "points"
self.results.order!("#{Comment.score_sql} DESC")
self.results.order!("score DESC")
end
end

View File

@ -39,8 +39,8 @@ class Story < ApplicationRecord
scope :deleted, -> { where(is_expired: true) }
scope :not_deleted, -> { where(is_expired: false) }
scope :unmerged, -> { where(:merged_story_id => nil) }
scope :positive_ranked, -> { where("#{Story.score_sql} >= 0") }
scope :low_scoring, ->(max = 5) { where("#{Story.score_sql} < ?", max) }
scope :positive_ranked, -> { where("score >= 0") }
scope :low_scoring, ->(max = 5) { where("score < ?", max) }
scope :front_page, -> { hottest.limit(StoriesPaginator::STORIES_PER_PAGE) }
scope :hottest, ->(user = nil, exclude_tags = nil) {
base.not_hidden_by(user)
@ -93,7 +93,7 @@ class Story < ApplicationRecord
scope :to_tweet, -> {
hottest(nil, Tag.where(tag: 'meta').pluck(:id))
.where(twitter_id: nil)
.where("#{Story.score_sql} >= 2")
.where("score >= 2")
.where("created_at >= ?", 2.days.ago)
.limit(10)
}
@ -112,10 +112,10 @@ class Story < ApplicationRecord
end
end
DOWNVOTABLE_DAYS = 14
FLAGGABLE_DAYS = 14
# the lowest a score can go
DOWNVOTABLE_MIN_SCORE = -5
FLAGGABLE_MIN_SCORE = -5
# after this many minutes old, a story cannot be edited
MAX_EDIT_MINS = (60 * 6)
@ -142,7 +142,7 @@ class Story < ApplicationRecord
:is_saved_by_cur_user, :moderation_reason, :previewing, :seen_previous, :vote
attr_writer :fetched_response
before_validation :assign_short_id_and_upvote, :on => :create
before_validation :assign_short_id_and_score, :on => :create
before_create :assign_initial_hotness
before_save :log_moderation
before_save :fix_bogus_chars
@ -285,15 +285,6 @@ class Story < ApplicationRecord
true
end
def self.score_sql
Arel.sql("(CAST(upvotes AS #{votes_cast_type}) - " <<
"CAST(downvotes AS #{votes_cast_type}))")
end
def self.votes_cast_type
Story.connection.adapter_name.match(/mysql/i) ? "signed" : "integer"
end
def archive_url
"https://archive.md/#{CGI.escape(self.url)}"
end
@ -306,8 +297,8 @@ class Story < ApplicationRecord
:title,
:url,
:score,
:upvotes,
:downvotes,
:score,
:flags,
{ :comment_count => :comments_count },
{ :description => :markeddown_description },
:comments_url,
@ -339,9 +330,9 @@ class Story < ApplicationRecord
self.hotness = self.calculated_hotness
end
def assign_short_id_and_upvote
def assign_short_id_and_score
self.short_id = ShortId.new(self.class).generate
self.upvotes = 1
self.score ||= 1 # tests are allowed to fake out the score
end
def calculated_hotness
@ -350,7 +341,7 @@ class Story < ApplicationRecord
base = self.tags.sum(:hotness_mod) + (self.user_is_author? && self.url.present? ? 0.25 : 0.0)
# give a story's comment votes some weight, ignoring submitter's comments
sum_expression = base < 0 ? "downvotes * -0.5" : "upvotes + 1 - downvotes"
sum_expression = base < 0 ? "flags * -0.5" : "score + 1"
cpoints = self.merged_comments.where.not(user_id: self.user_id).sum(sum_expression).to_f * 0.5
# mix in any stories this one cannibalized
@ -358,8 +349,9 @@ class Story < ApplicationRecord
# if a story has many comments but few votes, it's probably a bad story, so
# cap the comment points at the number of upvotes
if cpoints > self.upvotes
cpoints = self.upvotes
upvotes = self.score + self.flags
if cpoints > upvotes
cpoints = upvotes
end
# don't immediately kill stories at 0 by bumping up score by one
@ -484,14 +476,18 @@ class Story < ApplicationRecord
Markdowner.to_html(self.description, allow_images: self.can_have_images?)
end
def give_upvote_or_downvote_and_recalculate!(upvote, downvote)
self.upvotes += upvote.to_i
self.downvotes += downvote.to_i
Story.connection.execute("UPDATE #{Story.table_name} SET " <<
"upvotes = COALESCE(upvotes, 0) + #{upvote.to_i}, " <<
"downvotes = COALESCE(downvotes, 0) + #{downvote.to_i}, " <<
"hotness = '#{self.calculated_hotness}' WHERE id = #{self.id.to_i}")
# TODO: race condition: if two votes arrive at the same time, the second one
# won't take the first's score change into effect for calculated_hotness
def update_score_and_recalculate!(score_delta, flag_delta)
self.score += score_delta
self.flags += flag_delta
Story.connection.execute <<~SQL
UPDATE stories SET
score = (select sum(vote) from votes where story_id = stories.id and comment_id is null),
flags = (select count(*) from votes where story_id = stories.id and comment_id is null and vote = -1),
hotness = #{self.calculated_hotness}
WHERE id = #{self.id.to_i}
SQL
end
def has_suggestions?
@ -515,9 +511,9 @@ class Story < ApplicationRecord
c.join("")
end
def is_downvotable?
if self.created_at && self.score > DOWNVOTABLE_MIN_SCORE
Time.current - self.created_at <= DOWNVOTABLE_DAYS.days
def is_flaggable?
if self.created_at && self.score > FLAGGABLE_MIN_SCORE
Time.current - self.created_at <= FLAGGABLE_DAYS.days
else
false
end
@ -647,10 +643,6 @@ class Story < ApplicationRecord
Vote.vote_thusly_on_story_or_comment_for_user_because(1, self.id, nil, self.user_id, nil, false)
end
def score
upvotes - downvotes
end
def short_id_path
Rails.application.routes.url_helpers.root_path + "s/#{self.short_id}"
end

View File

@ -47,6 +47,6 @@ class StoryRepository
def top(length)
top = Story.base.where("created_at >= (NOW() - INTERVAL " <<
"#{length[:dur]} #{length[:intv].upcase})")
top.order("#{Story.score_sql} DESC")
top.order("score DESC")
end
end

View File

@ -137,14 +137,14 @@ class User < ApplicationRecord
# minimum karma required to be able to offer title/tag suggestions
MIN_KARMA_TO_SUGGEST = 10
# minimum karma required to be able to downvote comments
MIN_KARMA_TO_DOWNVOTE = 50
# minimum karma required to be able to flag comments
MIN_KARMA_TO_FLAG = 50
# minimum karma required to be able to submit new stories
MIN_KARMA_TO_SUBMIT_STORIES = -4
# minimum karma required to process invitation requests
MIN_KARMA_FOR_INVITATION_REQUESTS = MIN_KARMA_TO_DOWNVOTE
MIN_KARMA_FOR_INVITATION_REQUESTS = MIN_KARMA_TO_FLAG
# proportion of posts authored by user to consider as heavy self promoter
HEAVY_SELF_PROMOTER_PROPORTION = 0.51
@ -272,18 +272,18 @@ class User < ApplicationRecord
disabled_invite_at?
end
def can_downvote?(obj)
def can_flag?(obj)
if is_new?
return false
elsif obj.is_a?(Story)
if obj.is_downvotable?
if obj.is_flaggable?
return true
elsif obj.vote == -1
# user can unvote
return true
end
elsif obj.is_a?(Comment) && obj.is_downvotable?
return !self.is_new? && (self.karma >= MIN_KARMA_TO_DOWNVOTE)
elsif obj.is_a?(Comment) && obj.is_flaggable?
return !self.is_new? && (self.karma >= MIN_KARMA_TO_FLAG)
end
false
@ -360,7 +360,7 @@ class User < ApplicationRecord
def delete!
User.transaction do
self.comments
.where("upvotes - downvotes < 0")
.where("score < 0")
.find_each {|c| c.delete_for_user(self) }
self.sent_messages.each do |m|
@ -412,7 +412,7 @@ class User < ApplicationRecord
(self.comments.where('created_at >= now() - interval 30 day AND is_moderated').count +
self.stories.where('created_at >= now() - interval 30 day AND is_expired AND is_moderated')
.count >= 3) ||
DownvotedCommenters.new('90d').check_list_for(self)
FlaggedCommenters.new('90d').check_list_for(self)
end
def grant_moderatorship_by_user!(user)

View File

@ -86,40 +86,29 @@ class Vote < ApplicationRecord
end
def self.vote_thusly_on_story_or_comment_for_user_because(
vote, story_id, comment_id, user_id, reason, update_counters = true
new_vote, story_id, comment_id, user_id, reason, update_counters = true
)
v = Vote.where(:user_id => user_id, :story_id => story_id,
:comment_id => comment_id).first_or_initialize
# vote is already recorded, return
return if !v.new_record? && v.vote == vote
return if !v.new_record? && v.vote == new_vote # done if there's no change
# v.vote vote up down
# -1 1 1 -1
# 0 1 1 0
# -1 0 0 -1
# 1 0 -1 0
# 0 -1 0 1
# 1 -1 -1 1
if vote == 1
upvote = 1
elsif v.vote == 1
upvote = -1
score_delta = new_vote - v.vote.to_i
if v.vote == -1
# we know there's a change, so we must be removing a flag
flag_delta = -1
elsif new_vote == -1
# we know there's a change, so we must be adding a flag
flag_delta = 1
else
upvote = 0
end
if vote == -1
downvote = 1
elsif v.vote == -1
downvote = -1
else
downvote = 0
# change from 1 to 0 or 0 to 1, so number of flags doesn't change
flag_delta = 0
end
if vote == 0
if new_vote == 0
v.destroy!
else
v.vote = vote
v.vote = new_vote
v.reason = reason
v.save!
end
@ -127,9 +116,9 @@ class Vote < ApplicationRecord
if update_counters
t = v.target
if v.user_id != t.user_id
User.update_counters t.user_id, karma: upvote - downvote
User.update_counters t.user_id, karma: score_delta
end
t.give_upvote_or_downvote_and_recalculate!(upvote, downvote)
t.update_score_and_recalculate!(score_delta, flag_delta)
end
end

View File

@ -7,7 +7,7 @@
<div id="c_<%= comment.short_id %>"
data-shortid="<%= comment.short_id if comment.persisted? %>"
class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
"upvoted" : "downvoted") : "" %>
"upvoted" : "flagged") : "" %>
<%= comment.score < Comment::SCORE_RANGE_TO_HIDE.first ? "bad" : "" %>">
<% if defined?(show_tree_lines) && show_tree_lines %>
@ -16,7 +16,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% end %>
<% if !comment.is_gone? %>
<% can_downvote = @user && @user.can_downvote?(comment) %>
<% can_flag = @user && @user.can_flag?(comment) %>
<% score_display = comment.score_for_user(@user) %>
<div class="voters">
<% if @user %>
@ -25,14 +25,14 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<%= link_to "", login_path, :class => "upvoter" %>
<% end %>
<div class="score"><%= score_display %></div>
<% if can_downvote %>
<a class="downvoter"></a>
<% if can_flag %>
<a class="flagger"></a>
<% else %>
<span class="downvoter downvoter_stub"></span>
<span class="flagger flagger_stub"></span>
<% end %>
</div>
<div class="comment_parent_tree_line
<%= can_downvote ? "can_downvote" : "" %>
<%= can_flag ? "can_flag" : "" %>
<%= score_display != "&nbsp;" ? "score_shown" : "" %>
<%= defined?(children) && children ? "" : "no_children" %>"></div>
<% end %>
@ -117,7 +117,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% end %>
<span class="reason">
<% if comment.downvotes > 0 &&
<% if comment.flags > 0 &&
comment.show_score_to_user?(@user) &&
(comment.user_id == @user.try(:id) || @user.try("is_moderator?")) %>
| <%= comment.vote_summary_for_user(@user).downcase %>

View File

@ -16,7 +16,7 @@
<ul>
<li>a <a href="#tagging">tagging</a> system to categorize and filter submissions,</li>
<li>a user <a href="#invitations">invitation tree</a> to combat spam,</li>
<li><a href="#downvotes">downvote explanations</a> to curb reflexive downvoting,</li>
<li><a href="#flags">flag explanations</a> to curb punishing disagreement.</li>
<li>a strong commitment to <a href="#transparency">transparency</a>,</li>
<li>and <a href="#features">many other features</a> that have been added over the years.</li>
</ul>
@ -92,20 +92,26 @@
</p>
<p>
There's no limit on how many invitations a user can send (though that might be prompted by scaling problems in the future).
There's no limit on how many invitations a user can send (though we might at some point, to manage <a href="https://lobste.rs/stats">growth</a>).
When accounts are banned for spam, <a href="https://en.wikipedia.org/wiki/Sockpuppet_(Internet)">sockpuppeting</a>, or other abuse,
moderators will consider disabling their inviter's ability to send invitations or, rarely, also ban them.
</p>
<h2 id="downvotes">Downvote Explanations</h2>
<h2 id="flags">Flags</h2>
<p>
avoid meta
don't have downvotes to disagree
useful signal to mods
flag threshhold
Often on other sites, a user would have their comment downvoted without
explanation and then edit their comment to ask why they were downvoted. On
this site, voters must choose a reason before downvoting comments and those
votes are tallied and shown to the original commenter. Users may downvote
stories and comments after their account has
<%= User::MIN_KARMA_TO_DOWNVOTE %> karma.
<%= User::MIN_KARMA_TO_FLAG %> karma.
</p>
<p>
For submitted stories, downvoting is done through flagging (also requiring a
valid reason) and these flag summaries are shown to all users.
@ -114,9 +120,8 @@
<h2 id="transparency">Transparency Policy</h2>
<p>
All <a href="/moderations">moderator actions</a> on this site are visible to
everyone and the identities of those moderators are <a
href="/u?moderators=1">made public</a>. While the individual actions of a
moderator may cause debate, there should be no question about if an action happened or who is responsible.
everyone and the identities of those moderators are <a href="/u?moderators=1">public</a>.
While the individual actions of a moderator may cause debate, there should be no question about if an action happened or who is responsible.
</p>
<p>
@ -124,11 +129,11 @@
does not <a
href="http://www.righto.com/2013/11/how-hacker-news-ranking-really-works.html"
rel="nofollow">penalize</a> or prioritize specific users or domains.
Moderators have no ability to raise or lower the rankings of stories or comments besides voting like every other user.
Per-tag <a href="/filters">hotness modifiers</a> do affect all stories with
those tags, but these modifiers (and <a href="https://lobste.rs/moderations?utf8=%E2%9C%93&moderator=(All)&what[tags]=tags">changes</a> to them) are made public.
those tags, but these modifiers (and <a href="https://lobste.rs/moderations?utf8=%E2%9C%93&moderator=(All)&what[tags]=tags">changes</a> to them) are public.
Domains used for tracking are banned and tracking parameters are removed from links
(look for <tt>TRACKING_DOMAINS</tt> and <tt>utm_</tt> in
<a href="https://github.com/lobsters/lobsters/blob/master/app/models/story.rb">story.rb</a>).
(look for <tt>TRACKING_DOMAINS</tt> and <tt>utm_</tt> in <a href="https://github.com/lobsters/lobsters/blob/master/app/models/story.rb">story.rb</a>).
</p>
<p>
@ -150,7 +155,7 @@
The Lobsters community is in a sweet spot that it's large enough to be worth asking questions about and small enough the answers make sense.
We have <a href="/stats">some basic stats</a> available,
and Peter is happy to run queries against the <a href="https://github.com/lobsters/lobsters/blob/master/db/schema.rb">database</a> and Rails/MySQL/nginx logs (but not write them for you),
as long as they don't reveal personal info like IPs, browsing, and voting or create “worst-of” leaderboards celebrating most-downvoted users/comments/stories.
as long as they don't reveal personal info like IPs, browsing, and voting or create “worst-of” leaderboards celebrating most-flagged users/comments/stories.
If you're an academic researcher, please <a href="https://lobste.rs/s/cqnzl5/lobste_rs_access_pattern_statistics_for">be like MIT</a>, not like <a href="https://github.com/lobsters/lobsters/issues/517">UChicago</a> secretly experimenting on maintainers without IRB review.
</p>

View File

@ -1,8 +1,8 @@
<div class="box wide">
<div class="legend right">
<%= link_to 'Notes', mod_notes_path(period: '2w') %>
Flagged: <% @periods.each do |p| %><%= link_to_different_page(p, mod_flagged_path(period: p)) %> <% end %>
Downvoted: <% @periods.each do |p| %><%= link_to_different_page(p, mod_downvoted_path(period: p)) %> <% end %>
F-Stories: <% @periods.each do |p| %><%= link_to_different_page(p, mod_flagged_path(period: p)) %> <% end %>
F-Comments: <% @periods.each do |p| %><%= link_to_different_page(p, mod_flagged_path(period: p)) %> <% end %>
Commenters: <% %w{1m 2m 3m 6m}.each do |p| %><%= link_to_different_page(p, mod_commenters_path(period: p)) %> <% end %>
</div>

View File

@ -5,9 +5,9 @@
<tr>
<th>User</th>
<th title="Number of standard deviations above average commenter">+&sigma;</th>
<th class="r" title="Downvotes per comment">D/C</th>
<th class="r" title="total # of downvotes"># DV</th>
<th class="r" title="total # of downvoted comments"># C</th>
<th class="r" title="Flags per comment">D/C</th>
<th class="r" title="total # of comment flags"># FV</th>
<th class="r" title="total # of flagged comments"># C</th>
<th class="r" title="(percentage of all of users's comments)">% C</th>
</tr>
<% @commenters.each do |id, user| %><tr id="<%= user[:username] %>">
@ -16,13 +16,13 @@
<small>(<%= link_to 'threads', user_threads_path(user[:username]) %>)</small>
</td>
<td class="r"><%= '%.1f' % user[:sigma] %></td>
<td class="r"><%= '%.1f' % user[:average_downvotes] %></td>
<td class="r"><%= user[:n_downvotes] %></td>
<td class="r"><%= '%.1f' % user[:average_flags] %></td>
<td class="r"><%= user[:n_flags] %></td>
<td class="r"><%= user[:n_comments] %></td>
<td class="r"><%= user[:percent_downvoted].round %>%</td>
<td class="r"><%= user[:percent_flagged].round %>%</td>
</tr><% end %>
<tr>
<td title="Average of commenters, including the most-downvoted commenters above">
<td title="Average of commenters, including the most-flagged commenters above">
Avg of <%= @agg[:n_commenters] %> commenters
</td>
<td class="r" title="0 by definition; () is standard deviation">

View File

@ -4,7 +4,7 @@
%>
<li id="story_<%= story.short_id %>" data-shortid="<%= story.short_id %>"
class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<%= story.vote && story.vote[:vote] == -1 ? "downvoted" : "" %>
<%= story.vote && story.vote[:vote] == -1 ? "flagged" : "" %>
<%= story.score <= -1 ? "negative_1" : "" %>
<%= story.score <= -3 ? "negative_3" : "" %>
<%= story.score <= -5 ? "negative_5" : "" %>
@ -131,7 +131,7 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<% if @user && story.vote && story.vote[:vote] == -1 %>
| <a class="flagger">unflag (<%=
Vote::STORY_REASONS[story.vote[:reason]].to_s.downcase %>)</a>
<% elsif @user && @user.can_downvote?(story) %>
<% elsif @user && @user.can_flag?(story) %>
| <a class="flagger">flag</a>
<% end %>
<% if story.is_hidden_by_cur_user %>
@ -169,8 +169,8 @@ class="story <%= story.vote && story.vote[:vote] == 1 ? "upvoted" : "" %>
<% end %>
<% if defined?(single_story) && single_story &&
((story.downvotes > 0 && @user && @user.is_moderator?) ||
(story.downvotes >= 3 || story.score <= 0)) %>
((story.flags > 0 && @user && @user.is_moderator?) ||
(story.flags >= 3 || story.score <= 0)) %>
| <%= story.vote_summary_for(@user).downcase %>
<% end %>
<% end %>

View File

@ -166,9 +166,9 @@
<% if @user && @user.is_moderator? && !@showing_user.is_moderator? %>
<h2>Moderator Information</h2>
<label class="required">Downvoted (1m):</label>
<label class="required">Flagged (1m):</label>
<span class="d">
<% if (stats = DownvotedCommenters.new('1m').check_list_for(@showing_user)) %>
<% if (stats = FlaggedCommenters.new('1m').check_list_for(@showing_user)) %>
<a href="/mod/commenters/1m#<%= @showing_user.username %>">#<%= stats[:rank] %></a> at <%= '%.2f' % stats[:sigma] %> &sigma;
<% else %>
No

View File

@ -3,15 +3,15 @@
<p>
Most commenters get zero or very few flags across their comments in the last <%= @flag_warning_int[:human] %>.
Currently, users who see the warning have been flagged between <%= @dc_flagged.first %> and <%= @dc_flagged.last %> times.
Currently, users who see the warning have been flagged between <%= @fc_flagged.first %> and <%= @fc_flagged.last %> times.
Or, to make that visible:
</p>
<table class="standing">
<% (0..@dc_flagged.last.to_i).each do |n_flags| %>
<% (0..@fc_flagged.last.to_i).each do |n_flags| %>
<tr>
<td>
<% if @flagged_user_stats && n_flags == @flagged_user_stats[:n_downvotes] %>
<% if @flagged_user_stats && n_flags == @flagged_user_stats[:n_flags] %>
<div class="jaccuse">You</div>
<% else %>
<%= @lookup[n_flags].to_i %>&nbsp;<%= "user".pluralize(@lookup[n_flags].to_i) %>
@ -20,7 +20,7 @@
<td>
received&nbsp;<%= n_flags %>&nbsp;<%= "flag".pluralize(n_flags) %>
</td>
<% if n_flags < @dc_flagged.first.to_i %>
<% if n_flags < @fc_flagged.first.to_i %>
<td class="unwarned">
<% @lookup[n_flags].to_i.times do %><div>&#129438;</div><% end %>
</td>

View File

@ -75,7 +75,7 @@ Rails.application.routes.draw do
resources :stories, except: [:index] do
get '/stories/:short_id', to: redirect('/s/%{short_id}')
post "upvote"
post "downvote"
post "flag"
post "unvote"
post "undelete"
post "hide"
@ -94,7 +94,7 @@ Rails.application.routes.draw do
get "/comments/:id" => "comments#redirect_from_short_id"
get "reply"
post "upvote"
post "downvote"
post "flag"
post "unvote"
post "delete"
@ -199,8 +199,8 @@ Rails.application.routes.draw do
get "/moderators" => "users#tree", :moderators => true
get "/mod" => "mod#index"
get "/mod/flagged/:period" => "mod#flagged", :as => "mod_flagged"
get "/mod/downvoted/:period" => "mod#downvoted", :as => "mod_downvoted"
get "/mod/flagged_stories/:period" => "mod#flagged_stories", :as => "mod_flagged_stories"
get "/mod/flagged_comments/:period" => "mod#flagged_comments", :as => "mod_flagged_comments"
get "/mod/commenters/:period" => "mod#commenters", :as => "mod_commenters"
get "/mod/notes(/:period)" => "mod_notes#index", :as => "mod_notes"
post "/mod/notes" => "mod_notes#create"

View File

@ -0,0 +1,56 @@
class MemoizeScoreNotUpvotes < ActiveRecord::Migration[5.2]
def up
remove_index :comments, name: :downvote_index
rename_column :comments, :upvotes, :score
change_column :comments, :downvotes, :integer, default: 1, null: false, unsigned: true
rename_column :comments, :downvotes, :flags
ActiveRecord::Base.connection.execute <<~SQL
update comments
set score = coalesce((
select sum(vote)
from votes
where comment_id = comments.id
), 0)
SQL
ActiveRecord::Base.connection.execute <<~SQL
update comments
set flags = coalesce((
select count(vote)
from votes
where comment_id = comments.id
and vote = -1
), 0)
SQL
add_index :comments, :score
rename_column :stories, :upvotes, :score
change_column :stories, :score, :integer, default: 1, null: false
rename_column :stories, :downvotes, :flags
ActiveRecord::Base.connection.execute <<~SQL
update stories
set score = coalesce((
select sum(vote)
from votes
where story_id = stories.id and comment_id is null
), 0)
SQL
ActiveRecord::Base.connection.execute <<~SQL
update stories
set flags = coalesce((
select count(vote)
from votes
where story_id = stories.id and comment_id is null
and vote = -1
), 0)
SQL
add_index :stories, :score
replace_view :replying_comments, version: 9
end
# yeah, it's not technically irreversible, but I didn't want to write this
# whole dang thing twice when it's going to take an hour+ to see it work
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_06_07_192351) do
ActiveRecord::Schema.define(version: 2020_08_07_021926) do
create_table "comments", id: :bigint, unsigned: true, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", force: :cascade do |t|
t.datetime "created_at", null: false
@ -21,8 +21,8 @@ ActiveRecord::Schema.define(version: 2020_06_07_192351) do
t.bigint "parent_comment_id", unsigned: true
t.bigint "thread_id", unsigned: true
t.text "comment", limit: 16777215, null: false
t.integer "upvotes", default: 0, null: false
t.integer "downvotes", default: 0, null: false
t.integer "score", default: 0, null: false
t.integer "flags", default: 0, null: false, unsigned: true
t.decimal "confidence", precision: 20, scale: 19, default: "0.0", null: false
t.text "markeddown_comment", limit: 16777215
t.boolean "is_deleted", default: false
@ -33,10 +33,10 @@ ActiveRecord::Schema.define(version: 2020_06_07_192351) do
t.index ["confidence"], name: "confidence_idx"
t.index ["hat_id"], name: "comments_hat_id_fk"
t.index ["parent_comment_id"], name: "comments_parent_comment_id_fk"
t.index ["score"], name: "index_comments_on_score"
t.index ["short_id"], name: "short_id", unique: true
t.index ["story_id", "short_id"], name: "story_id_short_id"
t.index ["thread_id"], name: "thread_id"
t.index ["user_id", "story_id", "downvotes", "created_at"], name: "downvote_index"
t.index ["user_id"], name: "index_comments_on_user_id"
end
@ -185,8 +185,8 @@ ActiveRecord::Schema.define(version: 2020_06_07_192351) do
t.text "description", limit: 16777215
t.string "short_id", limit: 6, default: "", null: false
t.boolean "is_expired", default: false, null: false
t.integer "upvotes", default: 0, null: false, unsigned: true
t.integer "downvotes", default: 0, null: false, unsigned: true
t.integer "score", default: 0, null: false
t.integer "flags", default: 0, null: false, unsigned: true
t.boolean "is_moderated", default: false, null: false
t.decimal "hotness", precision: 20, scale: 10, default: "0.0", null: false
t.text "markeddown_description", limit: 16777215
@ -202,8 +202,9 @@ ActiveRecord::Schema.define(version: 2020_06_07_192351) do
t.index ["description"], name: "index_stories_on_description", type: :fulltext
t.index ["domain_id"], name: "index_stories_on_domain_id"
t.index ["hotness"], name: "hotness_idx"
t.index ["id", "is_expired", "is_moderated"], name: "index_stories_on_id_and_is_expired_and_is_moderated"
t.index ["id", "is_expired"], name: "index_stories_on_id_and_is_expired"
t.index ["merged_story_id"], name: "index_stories_on_merged_story_id"
t.index ["score"], name: "index_stories_on_score"
t.index ["short_id"], name: "unique_short_id", unique: true
t.index ["story_cache"], name: "index_stories_on_story_cache", type: :fulltext
t.index ["title"], name: "index_stories_on_title", type: :fulltext
@ -348,6 +349,6 @@ ActiveRecord::Schema.define(version: 2020_06_07_192351) do
add_foreign_key "votes", "users", name: "votes_user_id_fk"
create_view "replying_comments", sql_definition: <<-SQL
select `read_ribbons`.`user_id` AS `user_id`,`comments`.`id` AS `comment_id`,`read_ribbons`.`story_id` AS `story_id`,`comments`.`parent_comment_id` AS `parent_comment_id`,`comments`.`created_at` AS `comment_created_at`,`parent_comments`.`user_id` AS `parent_comment_author_id`,`comments`.`user_id` AS `comment_author_id`,`stories`.`user_id` AS `story_author_id`,`read_ribbons`.`updated_at` < `comments`.`created_at` AS `is_unread`,(select `votes`.`vote` from `votes` where `votes`.`user_id` = `read_ribbons`.`user_id` and `votes`.`comment_id` = `comments`.`id`) AS `current_vote_vote`,(select `votes`.`reason` from `votes` where `votes`.`user_id` = `read_ribbons`.`user_id` and `votes`.`comment_id` = `comments`.`id`) AS `current_vote_reason` from (((`read_ribbons` join `comments` on(`comments`.`story_id` = `read_ribbons`.`story_id`)) join `stories` on(`stories`.`id` = `comments`.`story_id`)) left join `comments` `parent_comments` on(`parent_comments`.`id` = `comments`.`parent_comment_id`)) where `read_ribbons`.`is_following` = 1 and `comments`.`user_id` <> `read_ribbons`.`user_id` and `comments`.`is_deleted` = 0 and `comments`.`is_moderated` = 0 and (`parent_comments`.`user_id` = `read_ribbons`.`user_id` or `parent_comments`.`user_id` is null and `stories`.`user_id` = `read_ribbons`.`user_id`) and `comments`.`upvotes` - `comments`.`downvotes` >= 0 and (`parent_comments`.`id` is null or `parent_comments`.`upvotes` - `parent_comments`.`downvotes` >= 0 and `parent_comments`.`is_moderated` = 0 and `parent_comments`.`is_deleted` = 0) and !exists(select 1 from (`votes` `f` join `comments` `c` on(`f`.`comment_id` = `c`.`id`)) where `f`.`vote` < 0 and `f`.`user_id` = `parent_comments`.`user_id` and `c`.`user_id` = `comments`.`user_id` and `f`.`story_id` = `comments`.`story_id` limit 1) and cast(`stories`.`upvotes` as signed) - cast(`stories`.`downvotes` as signed) >= 0
select `read_ribbons`.`user_id` AS `user_id`,`comments`.`id` AS `comment_id`,`read_ribbons`.`story_id` AS `story_id`,`comments`.`parent_comment_id` AS `parent_comment_id`,`comments`.`created_at` AS `comment_created_at`,`parent_comments`.`user_id` AS `parent_comment_author_id`,`comments`.`user_id` AS `comment_author_id`,`stories`.`user_id` AS `story_author_id`,`read_ribbons`.`updated_at` < `comments`.`created_at` AS `is_unread`,(select `votes`.`vote` from `votes` where `votes`.`user_id` = `read_ribbons`.`user_id` and `votes`.`comment_id` = `comments`.`id`) AS `current_vote_vote`,(select `votes`.`reason` from `votes` where `votes`.`user_id` = `read_ribbons`.`user_id` and `votes`.`comment_id` = `comments`.`id`) AS `current_vote_reason` from (((`read_ribbons` join `comments` on(`comments`.`story_id` = `read_ribbons`.`story_id`)) join `stories` on(`stories`.`id` = `comments`.`story_id`)) left join `comments` `parent_comments` on(`parent_comments`.`id` = `comments`.`parent_comment_id`)) where `read_ribbons`.`is_following` = 1 and `comments`.`user_id` <> `read_ribbons`.`user_id` and `comments`.`is_deleted` = 0 and `comments`.`is_moderated` = 0 and (`parent_comments`.`user_id` = `read_ribbons`.`user_id` or `parent_comments`.`user_id` is null and `stories`.`user_id` = `read_ribbons`.`user_id`) and `stories`.`score` >= 0 and `comments`.`score` >= 0 and (`parent_comments`.`id` is null or `parent_comments`.`score` >= 0 and `parent_comments`.`is_moderated` = 0 and `parent_comments`.`is_deleted` = 0) and !exists(select 1 from (`votes` `f` join `comments` `c` on(`f`.`comment_id` = `c`.`id`)) where `f`.`vote` < 0 and `f`.`user_id` = `parent_comments`.`user_id` and `c`.`user_id` = `comments`.`user_id` and `f`.`story_id` = `comments`.`story_id` limit 1)
SQL
end

View File

@ -15,7 +15,7 @@ User.create(
:is_moderator => true,
:karma => [
User::MIN_KARMA_TO_SUGGEST,
User::MIN_KARMA_TO_DOWNVOTE,
User::MIN_KARMA_TO_FLAG,
User::MIN_KARMA_TO_SUBMIT_STORIES,
User::MIN_KARMA_FOR_INVITATION_REQUESTS
].max,

View File

@ -0,0 +1,53 @@
SELECT
read_ribbons.user_id,
comments.id as comment_id,
read_ribbons.story_id as story_id,
comments.parent_comment_id,
comments.created_at as comment_created_at,
parent_comments.user_id as parent_comment_author_id,
comments.user_id as comment_author_id,
stories.user_id as story_author_id,
(read_ribbons.updated_at < comments.created_at) as is_unread,
(select votes.vote from votes where votes.user_id = read_ribbons.user_id and votes.comment_id = comments.id) as current_vote_vote,
(select votes.reason from votes where votes.user_id = read_ribbons.user_id and votes.comment_id = comments.id) as current_vote_reason
FROM
read_ribbons
JOIN
comments ON comments.story_id = read_ribbons.story_id
JOIN
stories ON stories.id = comments.story_id
LEFT JOIN
comments parent_comments ON parent_comments.id = comments.parent_comment_id
WHERE
read_ribbons.is_following = 1
AND comments.user_id != read_ribbons.user_id
AND comments.is_deleted = FALSE
AND comments.is_moderated = FALSE
AND (
parent_comments.user_id = read_ribbons.user_id
OR (parent_comments.user_id IS NULL
AND stories.user_id = read_ribbons.user_id)
)
-- neither the story nor comment have negative scores
AND stories.score >= 0
AND comments.score >= 0
AND (
parent_comments.id IS NULL
OR (
-- parent doesn't have negative score, isn't deleted, isn't moderated
parent_comments.score >= 0
AND parent_comments.is_moderated = FALSE
AND parent_comments.is_deleted = FALSE
)
)
AND ( -- parent user has not flagged replier in the thread
NOT EXISTS (
SELECT 1 FROM votes f JOIN comments c ON f.comment_id = c.id
WHERE
f.vote < 0
AND f.user_id = parent_comments.user_id
AND c.user_id = comments.user_id
AND f.story_id = comments.story_id
)
)
;

View File

@ -90,10 +90,10 @@ RSpec.feature "Commenting" do
stub_login_as user1
visit "/s/#{story.short_id}"
expect(page.find(:css, ".comment .comment_text")).to have_content('Cool story.')
expect(comment.upvotes).to eq(1)
expect(comment.score).to eq(1)
page.driver.post "/comments/#{comment.short_id}/upvote"
comment.reload
expect(comment.upvotes).to eq(2)
expect(comment.score).to eq(2)
story1.update(merged_stories: [story])
visit "/s/#{story1.short_id}"

View File

@ -318,8 +318,8 @@ describe Story do
end
before do
create(:comment, story: story, downvotes: 5, upvotes: 10)
create(:comment, story: story, downvotes: 10, upvotes: 5)
create(:comment, story: story, score: 1, flags: 5)
create(:comment, story: story, score: -9, flags: 10)
end
context "with positive base" do
@ -374,15 +374,13 @@ describe Story do
describe "scopes" do
context "recent" do
before do
it "returns the newest stories that have not yet reached the front page" do
create(:story, title: "Front Page")
create(:story, title: "Front Page 2")
flagged = create(:story, title: "New Story", score: -2, flags: 3)
expect(Story.front_page).to_not include(flagged)
create(:story, title: "New Story", downvotes: 3) # downvotes simulate story off 'front'
end
it "returns the newest stories that have not yet reached the front page" do
expect(Story.recent).to include Story.find_by(title: "New Story")
expect(Story.recent).to include(flagged)
expect(Story.recent).to_not include(Story.front_page)
end
end

View File

@ -10,6 +10,75 @@ describe Vote do
expect(v.errors[:vote]).to eq(["can't be blank"])
end
context "upvoting a score and flags" do
# don't need to test short-circuit where vote is "changed" to what it already is
let(:u) { create(:user) }
let(:s) { create(:story) }
it "-1 to 1" do
Vote.vote_thusly_on_story_or_comment_for_user_because(-1, s.id, nil, u.id, 'S')
s.reload
expect(s.score).to eq(0)
expect(s.flags).to eq(1)
Vote.vote_thusly_on_story_or_comment_for_user_because(1, s.id, nil, u.id, nil)
s.reload
expect(s.score).to eq(2)
expect(s.flags).to eq(0)
end
it "-1 to 0" do
Vote.vote_thusly_on_story_or_comment_for_user_because(-1, s.id, nil, u.id, 'S')
s.reload
expect(s.score).to eq(0)
expect(s.flags).to eq(1)
Vote.vote_thusly_on_story_or_comment_for_user_because(0, s.id, nil, u.id, nil)
s.reload
expect(s.score).to eq(1)
expect(s.flags).to eq(0)
end
it "0 to 1" do
expect(s.score).to eq(1)
expect(s.flags).to eq(0)
Vote.vote_thusly_on_story_or_comment_for_user_because(1, s.id, nil, u.id, nil)
s.reload
expect(s.score).to eq(2)
expect(s.flags).to eq(0)
end
it "1 to 0" do
Vote.vote_thusly_on_story_or_comment_for_user_because(1, s.id, nil, u.id, nil)
s.reload
expect(s.score).to eq(2)
expect(s.flags).to eq(0)
Vote.vote_thusly_on_story_or_comment_for_user_because(0, s.id, nil, u.id, nil)
s.reload
expect(s.score).to eq(1)
expect(s.flags).to eq(0)
end
it "0 to -1" do
expect(s.score).to eq(1)
expect(s.flags).to eq(0)
Vote.vote_thusly_on_story_or_comment_for_user_because(-1, s.id, nil, u.id, 'S')
s.reload
expect(s.score).to eq(0)
expect(s.flags).to eq(1)
end
it "1 to -1" do
Vote.vote_thusly_on_story_or_comment_for_user_because(1, s.id, nil, u.id, nil)
s.reload
expect(s.score).to eq(2)
expect(s.flags).to eq(0)
Vote.vote_thusly_on_story_or_comment_for_user_because(-1, s.id, nil, u.id, 'S')
s.reload
expect(s.score).to eq(0)
expect(s.flags).to eq(1)
end
end
it "has a limit on the reason field" do
s = create(:story)
u = create(:user)
@ -21,8 +90,8 @@ describe Vote do
it "applies a story upvote and karma properly" do
s = create(:story)
expect(s.upvotes).to eq(1)
expect(s.downvotes).to eq(0)
expect(s.score).to eq(1)
expect(s.flags).to eq(0)
expect(s.user.karma).to eq(0)
u = create(:user)
@ -31,7 +100,7 @@ describe Vote do
s.reload
expect(s.upvotes).to eq(2)
expect(s.score).to eq(2)
expect(s.user.karma).to eq(1)
end
@ -45,22 +114,22 @@ describe Vote do
s.reload
expect(s.upvotes).to eq(2)
expect(s.score).to eq(2)
expect(s.user.karma).to eq(1)
end
end
it "has no effect on a story score when casting a hide vote" do
s = create(:story)
expect(s.upvotes).to eq(1)
expect(s.score).to eq(1)
u = create(:user)
Vote.vote_thusly_on_story_or_comment_for_user_because(0, s.id, nil, u.id, "H")
s.reload
expect(s.user.karma).to eq(0)
expect(s.upvotes).to eq(1)
expect(s.downvotes).to eq(0)
expect(s.score).to eq(1)
expect(s.flags).to eq(0)
end
it "removes karma and upvote when downvoting an upvote" do
@ -74,8 +143,8 @@ describe Vote do
c.reload
expect(c.user.karma).to eq(1)
# initial poster upvote plus new user's vote
expect(c.upvotes).to eq(2)
expect(c.downvotes).to eq(0)
expect(c.score).to eq(2)
expect(c.flags).to eq(0)
# flip vote
Vote.vote_thusly_on_story_or_comment_for_user_because(
@ -84,27 +153,27 @@ describe Vote do
c.reload
expect(c.user.karma).to eq(-1)
expect(c.upvotes).to eq(1)
expect(c.downvotes).to eq(1)
expect(c.score).to eq(0)
expect(c.flags).to eq(1)
end
it "neutralizes karma and upvote when unvoting an upvote" do
s = create(:story)
c = create(:comment, :story_id => s.id)
u = create(:user)
expect(c.user.karma).to eq(0)
Vote.vote_thusly_on_story_or_comment_for_user_because(1, s.id, c.id, u.id, nil)
c.reload
expect(c.user.karma).to eq(1)
expect(c.upvotes).to eq(2)
expect(c.downvotes).to eq(0)
expect(c.score).to eq(2)
expect(c.flags).to eq(0)
Vote.vote_thusly_on_story_or_comment_for_user_because(0, s.id, c.id, u.id, nil)
c.reload
expect(c.user.karma).to eq(0)
expect(c.upvotes).to eq(1)
expect(c.downvotes).to eq(0)
expect(c.score).to eq(1)
expect(c.flags).to eq(0)
end
end

View File

@ -23,7 +23,7 @@ describe 'merged stories', type: :request do
merged_story.reload
expect(merged_story.merged_into_story).to eq story
expect(merged_story.upvotes).to eq(1)
expect(merged_story.score).to eq(1)
end
it "has no effect when hiding merged story" do

View File

@ -15,15 +15,15 @@ describe 'users controller' do
let!(:bad_user) { create(:user) }
before do
dc = double('DownvotedCommenters')
fc = double('FlaggedCommenters')
bad_user_stats = {
n_downvotes: 15,
n_flags: 15,
}
allow(dc).to receive(:commenters).and_return({
allow(fc).to receive(:commenters).and_return({
bad_user.id => bad_user_stats,
})
allow(dc).to receive(:check_list_for).and_return(bad_user_stats)
allow(DownvotedCommenters).to receive(:new).and_return(dc)
allow(fc).to receive(:check_list_for).and_return(bad_user_stats)
allow(FlaggedCommenters).to receive(:new).and_return(fc)
end
it "displays to the user" do