From 5d4ee2ca1d746abcd2cb15e80a56418f614cff79 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Fri, 18 Mar 2016 11:17:51 -0400 Subject: [PATCH] FEATURE: Warn a user when they have few likes remaining --- .../javascripts/discourse/mixins/ajax.js | 4 ++++ .../discourse/models/action-summary.js.es6 | 20 ++++++++++------- .../javascripts/discourse/widgets/post.js.es6 | 22 ++++++++++++++++++- .../discourse/widgets/widget.js.es6 | 1 + app/controllers/post_actions_controller.rb | 6 +++++ config/locales/client.en.yml | 2 ++ lib/rate_limiter.rb | 9 ++++++++ spec/components/rate_limiter_spec.rb | 16 ++++++++++---- spec/models/post_action_spec.rb | 12 ++++++---- 9 files changed, 75 insertions(+), 17 deletions(-) diff --git a/app/assets/javascripts/discourse/mixins/ajax.js b/app/assets/javascripts/discourse/mixins/ajax.js index 36e42f512..d68d457d2 100644 --- a/app/assets/javascripts/discourse/mixins/ajax.js +++ b/app/assets/javascripts/discourse/mixins/ajax.js @@ -66,6 +66,10 @@ Discourse.Ajax = Em.Mixin.create({ }); } + if (args.returnXHR) { + data = { result: data, xhr }; + } + Ember.run(null, resolve, data); }; diff --git a/app/assets/javascripts/discourse/models/action-summary.js.es6 b/app/assets/javascripts/discourse/models/action-summary.js.es6 index a39d78fb5..1a58dc4a0 100644 --- a/app/assets/javascripts/discourse/models/action-summary.js.es6 +++ b/app/assets/javascripts/discourse/models/action-summary.js.es6 @@ -18,10 +18,7 @@ export default RestModel.extend({ }, togglePromise(post) { - if (!this.get('acted')) { - return this.act(post).then(() => true); - } - return this.undo(post).then(() => false); + return this.get('acted') ? this.undo(post) : this.act(post); }, toggle(post) { @@ -64,11 +61,15 @@ export default RestModel.extend({ message: opts.message, take_action: opts.takeAction, flag_topic: this.get('flagTopic') ? true : false - } - }).then(function(result) { + }, + returnXHR: true, + }).then(function(data) { if (!self.get('flagTopic')) { - return post.updateActionsSummary(result); + post.updateActionsSummary(data.result); } + const remaining = parseInt(data.xhr.getResponseHeader('Discourse-Actions-Remaining') || 0); + const max = parseInt(data.xhr.getResponseHeader('Discourse-Actions-Max') || 0); + return { acted: true, remaining, max }; }).catch(function(error) { popupAjaxError(error); self.removeAction(post); @@ -83,7 +84,10 @@ export default RestModel.extend({ return Discourse.ajax("/post_actions/" + post.get('id'), { type: 'DELETE', data: { post_action_type_id: this.get('id') } - }).then(result => post.updateActionsSummary(result)); + }).then(result => { + post.updateActionsSummary(result); + return { acted: false }; + }); }, deferFlags(post) { diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 index f99210140..ffa1b1df6 100644 --- a/app/assets/javascripts/discourse/widgets/post.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -423,7 +423,27 @@ export default createWidget('post', { const likeAction = post.get('likeAction'); if (likeAction && likeAction.get('canToggle')) { - return likeAction.togglePromise(post); + return likeAction.togglePromise(post).then(result => this._warnIfClose(result)); + } + }, + + _warnIfClose(result) { + if (!result || !result.acted) { return; } + + const kvs = this.keyValueStore; + const lastWarnedLikes = kvs.get('lastWarnedLikes'); + + // only warn once per day + const yesterday = new Date().getTime() - 1000 * 60 * 60 * 24; + if (lastWarnedLikes && parseInt(lastWarnedLikes) > yesterday) { + return; + } + + const { remaining, max } = result; + const threshold = Math.ceil(max * 0.1); + if (remaining === threshold) { + bootbox.alert(I18n.t('post.few_likes_left')); + kvs.set({ key: 'lastWarnedLikes', value: new Date().getTime() }); } }, diff --git a/app/assets/javascripts/discourse/widgets/widget.js.es6 b/app/assets/javascripts/discourse/widgets/widget.js.es6 index c05b2500e..63f693048 100644 --- a/app/assets/javascripts/discourse/widgets/widget.js.es6 +++ b/app/assets/javascripts/discourse/widgets/widget.js.es6 @@ -119,6 +119,7 @@ export default class Widget { this.currentUser = container.lookup('current-user:main'); this.store = container.lookup('store:main'); this.appEvents = container.lookup('app-events:main'); + this.keyValueStore = container.lookup('key-value-store:main'); if (this.name) { const custom = _customSettings[this.name]; diff --git a/app/controllers/post_actions_controller.rb b/app/controllers/post_actions_controller.rb index 4a7585890..c23d2ed6f 100644 --- a/app/controllers/post_actions_controller.rb +++ b/app/controllers/post_actions_controller.rb @@ -21,6 +21,12 @@ class PostActionsController < ApplicationController else # We need to reload or otherwise we are showing the old values on the front end @post.reload + + if @post_action_type_id == PostActionType.types[:like] + limiter = post_action.post_action_rate_limiter + response.headers['Discourse-Actions-Remaining'] = limiter.remaining.to_s + response.headers['Discourse-Actions-Max'] = limiter.max.to_s + end render_post_json(@post, _add_raw = false) end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0ff27a0c4..2ab59d130 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1511,6 +1511,8 @@ en: archetypes: save: 'Save Options' + few_likes_left: "Warning: You have few likes left to give today. Use them wisely!" + controls: reply: "begin composing a reply to this post" like: "like this post" diff --git a/lib/rate_limiter.rb b/lib/rate_limiter.rb index df678960d..0985db911 100644 --- a/lib/rate_limiter.rb +++ b/lib/rate_limiter.rb @@ -67,6 +67,15 @@ class RateLimiter $redis.lpop(@key) end + def remaining + return @max if @user && @user.staff? + + arr = $redis.lrange(@key, 0, @max) || [] + t0 = Time.now.to_i + arr.reject! {|a| (t0 - a.to_i) > @secs} + @max - arr.size + end + private def seconds_to_wait diff --git a/spec/components/rate_limiter_spec.rb b/spec/components/rate_limiter_spec.rb index e997db7c6..1cf43b11c 100644 --- a/spec/components/rate_limiter_spec.rb +++ b/spec/components/rate_limiter_spec.rb @@ -39,6 +39,16 @@ describe RateLimiter do end end + context "remaining" do + it "updates correctly" do + expect(rate_limiter.remaining).to eq(2) + rate_limiter.performed! + expect(rate_limiter.remaining).to eq(1) + rate_limiter.performed! + expect(rate_limiter.remaining).to eq(0) + end + end + context "multiple calls" do before do rate_limiter.performed! @@ -47,6 +57,7 @@ describe RateLimiter do it "returns false for can_perform when the limit has been hit" do expect(rate_limiter.can_perform?).to eq(false) + expect(rate_limiter.remaining).to eq(0) end it "raises an error the third time called" do @@ -54,10 +65,10 @@ describe RateLimiter do end context "as an admin/moderator" do - it "returns true for can_perform if the user is an admin" do user.admin = true expect(rate_limiter.can_perform?).to eq(true) + expect(rate_limiter.remaining).to eq(2) end it "doesn't raise an error when an admin performs the task" do @@ -74,8 +85,6 @@ describe RateLimiter do user.moderator = true expect { rate_limiter.performed! }.not_to raise_error end - - end context "rollback!" do @@ -90,7 +99,6 @@ describe RateLimiter do it "raises no error now that there is room" do expect { rate_limiter.performed! }.not_to raise_error end - end end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb index d3b2a9ad4..e5809521e 100644 --- a/spec/models/post_action_spec.rb +++ b/spec/models/post_action_spec.rb @@ -12,6 +12,10 @@ describe PostAction do let(:second_post) { Fabricate(:post, topic_id: post.topic_id) } let(:bookmark) { PostAction.new(user_id: post.user_id, post_action_type_id: PostActionType.types[:bookmark] , post_id: post.id) } + def value_for(user_id, dt) + GivenDailyLike.find_for(user_id, dt).pluck(:likes_given)[0] || 0 + end + describe "rate limits" do it "limits redo/undo" do @@ -172,7 +176,7 @@ describe PostAction do # we need this to test it TopicUser.change(codinghorror, post.topic, posted: true) - expect(GivenDailyLike.value_for(moderator.id, Date.today)).to eq(0) + expect(value_for(moderator.id, Date.today)).to eq(0) PostAction.act(moderator, post, PostActionType.types[:like]) PostAction.act(codinghorror, second_post, PostActionType.types[:like]) @@ -180,7 +184,7 @@ describe PostAction do post.topic.reload expect(post.topic.like_count).to eq(2) - expect(GivenDailyLike.value_for(moderator.id, Date.today)).to eq(1) + expect(value_for(moderator.id, Date.today)).to eq(1) tu = TopicUser.get(post.topic, codinghorror) expect(tu.liked).to be true @@ -251,7 +255,7 @@ describe PostAction do expect(post.like_score).to eq(1) post.topic.reload expect(post.topic.like_count).to eq(1) - expect(GivenDailyLike.value_for(codinghorror.id, Date.today)).to eq(1) + expect(value_for(codinghorror.id, Date.today)).to eq(1) # When a staff member likes it PostAction.act(moderator, post, PostActionType.types[:like]) @@ -264,7 +268,7 @@ describe PostAction do post.reload expect(post.like_count).to eq(1) expect(post.like_score).to eq(3) - expect(GivenDailyLike.value_for(codinghorror.id, Date.today)).to eq(0) + expect(value_for(codinghorror.id, Date.today)).to eq(0) PostAction.remove_act(moderator, post, PostActionType.types[:like]) post.reload