diff --git a/app/assets/javascripts/discourse/components/small-action.js.es6 b/app/assets/javascripts/discourse/components/small-action.js.es6 index e4279230dc4..941dd440c99 100644 --- a/app/assets/javascripts/discourse/components/small-action.js.es6 +++ b/app/assets/javascripts/discourse/components/small-action.js.es6 @@ -4,7 +4,9 @@ const icons = { 'archived.enabled': 'folder', 'archived.disabled': 'folder-open', 'pinned.enabled': 'thumb-tack', - 'pinned.disabled': 'thumb-tack', + 'pinned.disabled': 'thumb-tack unpinned', + 'pinned_globally.enabled': 'thumb-tack', + 'pinned_globally.disabled': 'thumb-tack unpinned', 'visible.enabled': 'eye', 'visible.disabled': 'eye-slash' }; diff --git a/app/assets/javascripts/discourse/controllers/feature-topic.js.es6 b/app/assets/javascripts/discourse/controllers/feature-topic.js.es6 index a00c79a5b32..990d2b83ae0 100644 --- a/app/assets/javascripts/discourse/controllers/feature-topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/feature-topic.js.es6 @@ -10,15 +10,23 @@ export default ObjectController.extend(ModalFunctionality, { pinnedGloballyCount: 0, bannerCount: 0, + reset: function() { + this.set("model.pinnedInCategoryUntil", null); + this.set("model.pinnedGloballyUntil", null); + }, + categoryLink: function() { return categoryLinkHTML(this.get("model.category"), { allowUncategorized: true }); }.property("model.category"), unPinMessage: function() { - return this.get("model.pinned_globally") ? - I18n.t("topic.feature_topic.unpin_globally") : - I18n.t("topic.feature_topic.unpin", { categoryLink: this.get("categoryLink") }); - }.property("categoryLink", "model.pinned_globally"), + let name = "topic.feature_topic.unpin"; + if (this.get("model.pinned_globally")) name += "_globally"; + if (moment(this.get("model.pinned_until")) > moment()) name += "_until"; + const until = moment(this.get("model.pinned_until")).format("LL"); + + return I18n.t(name, { categoryLink: this.get("categoryLink"), until: until }); + }.property("categoryLink", "model.{pinned_globally,pinned_until}"), pinMessage: function() { return I18n.t("topic.feature_topic.pin", { categoryLink: this.get("categoryLink") }); @@ -28,6 +36,30 @@ export default ObjectController.extend(ModalFunctionality, { return I18n.t("topic.feature_topic.already_pinned", { categoryLink: this.get("categoryLink"), count: this.get("pinnedInCategoryCount") }); }.property("categoryLink", "pinnedInCategoryCount"), + pinDisabled: function() { + return !this._isDateValid(this.get("parsedPinnedInCategoryUntil")); + }.property("parsedPinnedInCategoryUntil"), + + pinGloballyDisabled: function() { + return !this._isDateValid(this.get("parsedPinnedGloballyUntil")); + }.property("pinnedGloballyUntil"), + + parsedPinnedInCategoryUntil: function() { + return this._parseDate(this.get("model.pinnedInCategoryUntil")); + }.property("model.pinnedInCategoryUntil"), + + parsedPinnedGloballyUntil: function() { + return this._parseDate(this.get("model.pinnedGloballyUntil")); + }.property("model.pinnedGloballyUntil"), + + _parseDate(date) { + return moment(date, ["YYYY-MM-DD", "YYYY-MM-DD HH:mm"]); + }, + + _isDateValid(parsedDate) { + return parsedDate.isValid() && parsedDate > moment(); + }, + onShow() { this.set("loading", true); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 066b000a269..aace6f8e0f5 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -99,19 +99,6 @@ export default ObjectController.extend(SelectedPostsCount, BufferedContent, { this.set('selectedReplies', []); }.on('init'), - _togglePinnedStates(property) { - const value = this.get('model.pinned_at') ? false : true, - topic = this.get('content'); - - // optimistic update - topic.setProperties({ - pinned_at: value, - pinned_globally: value - }); - - return topic.saveStatus(property, value); - }, - actions: { deleteTopic() { this.deleteTopic(); @@ -371,27 +358,31 @@ export default ObjectController.extend(SelectedPostsCount, BufferedContent, { togglePinned() { const value = this.get('model.pinned_at') ? false : true, - topic = this.get('content'); + topic = this.get('content'), + until = this.get('model.pinnedInCategoryUntil'); // optimistic update topic.setProperties({ pinned_at: value ? moment() : null, - pinned_globally: false + pinned_globally: false, + pinned_until: value ? until : null }); - return topic.saveStatus("pinned", value); + return topic.saveStatus("pinned", value, until); }, pinGlobally() { - const topic = this.get('content'); + const topic = this.get('content'), + until = this.get('model.pinnedGloballyUntil'); // optimistic update topic.setProperties({ pinned_at: moment(), - pinned_globally: true + pinned_globally: true, + pinned_until: until }); - return topic.saveStatus("pinned_globally", true); + return topic.saveStatus("pinned_globally", true, until); }, toggleArchived() { diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 2481f2574b8..5d69700184c 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -154,13 +154,17 @@ const Topic = RestModel.extend({ this.saveStatus(property, !!this.get(property)); }, - saveStatus(property, value) { + saveStatus(property, value, until) { if (property === 'closed' && value === true) { this.set('details.auto_close_at', null); } return Discourse.ajax(this.get('url') + "/status", { type: 'PUT', - data: { status: property, enabled: !!value } + data: { + status: property, + enabled: !!value, + until: until + } }); }, diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index 963afc35ceb..63762c7b554 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -61,6 +61,7 @@ const TopicRoute = Discourse.Route.extend(ShowFooter, { showFeatureTopic() { showModal('featureTopic', { model: this.modelFor('topic'), title: 'topic.feature_topic.title' }); this.controllerFor('modal').set('modalClass', 'feature-topic-modal'); + this.controllerFor('feature_topic').reset(); }, showInvite() { diff --git a/app/assets/javascripts/discourse/templates/modal/feature-topic.hbs b/app/assets/javascripts/discourse/templates/modal/feature-topic.hbs index 73e0176055f..07b64307953 100644 --- a/app/assets/javascripts/discourse/templates/modal/feature-topic.hbs +++ b/app/assets/javascripts/discourse/templates/modal/feature-topic.hbs @@ -1,80 +1,94 @@ diff --git a/app/assets/stylesheets/common/base/topic-admin-menu.scss b/app/assets/stylesheets/common/base/topic-admin-menu.scss index fb7b3b6f933..79cb3cbbdba 100644 --- a/app/assets/stylesheets/common/base/topic-admin-menu.scss +++ b/app/assets/stylesheets/common/base/topic-admin-menu.scss @@ -30,30 +30,36 @@ } } -.modal-body.feature-topic .feature-section { - display: block; - .button { - width: 33%; - display: inline-block; - vertical-align: top; - margin-top: 15px; +.modal-body.feature-topic { + padding: 5px; + max-height: 500px; + hr { + margin: 10px 0; } - .desc { - display: inline-block; - vertical-align: middle; - max-width: 60%; - margin-left: 10px; - p { - margin: 10px 0; + .feature-section { + display: block; + .badge-wrapper { + margin-right: 0; + } + input[type="date"] { + width: 120px; + margin: 0; + } + .desc { + display: inline-block; + vertical-align: middle; + margin-left: 10px; + p:first-of-type { + margin: 0; + } + p { + margin: 10px 0 0; + } } } } + .mobile-view .feature-topic .feature-section { - .button { - width: auto; - display: block; - margin: 0 10px; - } .desc { display: block; clear: both; diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 8c7c69c11f5..88e30c39246 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -165,13 +165,16 @@ class TopicsController < ApplicationController def status params.require(:status) params.require(:enabled) - status, topic_id = params[:status], params[:topic_id].to_i - enabled = (params[:enabled] == 'true') + params.permit(:until) + + status = params[:status] + topic_id = params[:topic_id].to_i + enabled = params[:enabled] == 'true' check_for_status_presence(:status, status) @topic = Topic.find_by(id: topic_id) guardian.ensure_can_moderate!(@topic) - @topic.update_status(status, enabled, current_user) + @topic.update_status(status, enabled, current_user, until: params[:until]) render nothing: true end diff --git a/app/jobs/base.rb b/app/jobs/base.rb index 8127d755bed..5441f26e87b 100644 --- a/app/jobs/base.rb +++ b/app/jobs/base.rb @@ -227,7 +227,8 @@ module Jobs end def self.enqueue_at(datetime, job_name, opts={}) - enqueue_in( [(datetime - Time.zone.now).to_i, 0].max, job_name, opts ) + secs = [(datetime - Time.zone.now).to_i, 0].max + enqueue_in(secs, job_name, opts) end def self.cancel_scheduled_job(job_name, params={}) diff --git a/app/jobs/regular/unpin_topic.rb b/app/jobs/regular/unpin_topic.rb new file mode 100644 index 00000000000..ff894ca6bc9 --- /dev/null +++ b/app/jobs/regular/unpin_topic.rb @@ -0,0 +1,16 @@ +module Jobs + + class UnpinTopic < Jobs::Base + + def execute(args) + topic_id = args[:topic_id] + + raise Discourse::InvalidParameters.new(:topic_id) unless topic_id.present? + + topic = Topic.find_by(id: topic_id) + topic.update_pinned(false) if topic.present? + end + + end + +end diff --git a/app/jobs/scheduled/ensure_db_consistency.rb b/app/jobs/scheduled/ensure_db_consistency.rb index 9dd2a162b8d..609dd3184ac 100644 --- a/app/jobs/scheduled/ensure_db_consistency.rb +++ b/app/jobs/scheduled/ensure_db_consistency.rb @@ -11,6 +11,7 @@ module Jobs TopicFeaturedUsers.ensure_consistency! PostRevision.ensure_consistency! UserStat.update_view_counts(13.hours.ago) + Topic.ensure_consistency! end end end diff --git a/app/models/post_action.rb b/app/models/post_action.rb index e15e025de1e..90c25b715c0 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -465,7 +465,7 @@ SQL # the threshold has been reached, we will close the topic waiting for intervention message = I18n.t("temporarily_closed_due_to_flags") - topic.update_status("closed", true, Discourse.system_user, message) + topic.update_status("closed", true, Discourse.system_user, message: message) end def self.auto_hide_if_needed(acting_user, post, post_action_type) diff --git a/app/models/topic.rb b/app/models/topic.rb index 94c5b1a159b..60ad4cc567c 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -406,8 +406,8 @@ class Topic < ActiveRecord::Base similar end - def update_status(status, enabled, user, message=nil) - TopicStatusUpdate.new(self, user).update!(status, enabled, message) + def update_status(status, enabled, user, opts={}) + TopicStatusUpdate.new(self, user).update!(status, enabled, opts) end # Atomically creates the next post number @@ -726,9 +726,17 @@ class Topic < ActiveRecord::Base TopicUser.change(user.id, id, cleared_pinned_at: nil) end - def update_pinned(status, global=false) - update_column(:pinned_at, status ? Time.now : nil) - update_column(:pinned_globally, global) + def update_pinned(status, global=false, pinned_until=nil) + pinned_until = Time.parse(pinned_until) rescue nil + + update_columns( + pinned_at: status ? Time.now : nil, + pinned_globally: global, + pinned_until: pinned_until + ) + + Jobs.cancel_scheduled_job(:unpin_topic, topic_id: self.id) + Jobs.enqueue_at(pinned_until, :unpin_topic, topic_id: self.id) if pinned_until end def draft_key @@ -745,6 +753,11 @@ class Topic < ActiveRecord::Base end end + def self.ensure_consistency! + # unpin topics that might have been missed + Topic.where("pinned_until < now()").update_all(pinned_at: nil, pinned_globally: false, pinned_until: nil) + end + def self.auto_close Topic.where("NOT closed AND auto_close_at < ? AND auto_close_user_id IS NOT NULL", 1.minute.ago).each do |t| t.auto_close diff --git a/app/models/topic_status_update.rb b/app/models/topic_status_update.rb index 2f8c9f990e6..64dcc256e80 100644 --- a/app/models/topic_status_update.rb +++ b/app/models/topic_status_update.rb @@ -1,21 +1,20 @@ TopicStatusUpdate = Struct.new(:topic, :user) do - def update!(status, enabled, message=nil) + def update!(status, enabled, opts={}) status = Status.new(status, enabled) Topic.transaction do - change(status) + change(status, opts) highest_post_number = topic.highest_post_number - - create_moderator_post_for(status, message) + create_moderator_post_for(status, opts[:message]) update_read_state_for(status, highest_post_number) end end private - def change(status) + def change(status, opts={}) if status.pinned? || status.pinned_globally? - topic.update_pinned(status.enabled?, status.pinned_globally?) + topic.update_pinned(status.enabled?, status.pinned_globally?, opts[:until]) elsif status.autoclosed? topic.update_column('closed', status.enabled?) else diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index 1fd56f54a13..9cda8e1c01a 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -43,6 +43,7 @@ class TopicViewSerializer < ApplicationSerializer :pinned_globally, :pinned, # Is topic pinned and viewer hasn't cleared the pin? :pinned_at, # Ignores clear pin + :pinned_until, :details, :highest_post_number, :last_read_post_number, @@ -177,6 +178,10 @@ class TopicViewSerializer < ApplicationSerializer object.topic.pinned_at end + def pinned_until + object.topic.pinned_until + end + def actions_summary result = [] return [] unless post = object.posts.try(:first) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 843c38771bc..7c0c49ac64d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -130,11 +130,11 @@ en: enabled: 'pinned this topic %{when}' disabled: 'unpinned this topic %{when}' pinned_globally: - enabled: 'pinned this topic %{when}' + enabled: 'pinned globally this topic %{when}' disabled: 'unpinned this topic %{when}' visible: - enabled: 'unlisted this topic %{when}' - disabled: 'listed this topic %{when}' + enabled: 'listed this topic %{when}' + disabled: 'unlisted this topic %{when}' topic_admin_menu: "topic admin actions" @@ -1131,17 +1131,19 @@ en: feature_topic: title: "Feature this topic" - pin: "Make this topic appear at the top of the {{categoryLink}} category." + pin: "Make this topic appear at the top of the {{categoryLink}} category until" confirm_pin: "You already have {{count}} pinned topics. Too many pinned topics may be a burden for new and anonymous users. Are you sure you want to pin another topic in this category?" unpin: "Remove this topic from the top of the {{categoryLink}} category." + unpin_until: "Remove this topic from the top of the {{categoryLink}} category or wait until %{until}." pin_note: "Users can unpin the topic individually for themselves." already_pinned: zero: "There are no topics pinned in {{categoryLink}}." one: "Topics currently pinned in {{categoryLink}}: 1." other: "Topics currently pinned in {{categoryLink}}: {{count}}." - pin_globally: "Make this topic appear at the top of all topic lists, until a staff member unpins it." + pin_globally: "Make this topic appear at the top of all topic lists until" confirm_pin_globally: "You already have {{count}} globally pinned topics. Too many pinned topics may be a burden for new and anonymous users. Are you sure you want to pin another topic globally?" unpin_globally: "Remove this topic from the top of all topic lists." + unpin_globally_until: "Remove this topic from the top of all topic lists or wait until %{until}." global_pin_note: "Users can unpin the topic individually for themselves." already_pinned_globally: zero: "There are no topics pinned globally." diff --git a/db/migrate/20150727210019_add_pinned_until_to_topics.rb b/db/migrate/20150727210019_add_pinned_until_to_topics.rb new file mode 100644 index 00000000000..992448416be --- /dev/null +++ b/db/migrate/20150727210019_add_pinned_until_to_topics.rb @@ -0,0 +1,5 @@ +class AddPinnedUntilToTopics < ActiveRecord::Migration + def change + add_column :topics, :pinned_until, :datetime, null: true + end +end diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index 049359715ba..8994e5de9c2 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -324,12 +324,12 @@ describe TopicsController do end it 'calls update_status on the forum topic with false' do - Topic.any_instance.expects(:update_status).with('closed', false, @user) + Topic.any_instance.expects(:update_status).with('closed', false, @user, until: nil) xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'false' end it 'calls update_status on the forum topic with true' do - Topic.any_instance.expects(:update_status).with('closed', true, @user) + Topic.any_instance.expects(:update_status).with('closed', true, @user, until: nil) xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'true' end