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 @@
{{#if model.pinned_at}}
-
- {{d-button action="unpin" icon="thumb-tack" label="topic.feature.unpin" class="btn-primary"}}
-
-
{{{unPinMessage}}}
{{#if model.pinned_globally}}
-
{{i18n "topic.feature_topic.global_pin_note"}}
{{#conditional-loading-spinner size="small" condition=loading}}
{{{i18n "topic.feature_topic.already_pinned_globally" count=pinnedGloballyCount}}}
{{/conditional-loading-spinner}}
+
{{i18n "topic.feature_topic.global_pin_note"}}
{{else}}
-
{{i18n "topic.feature_topic.pin_note"}}
{{#conditional-loading-spinner size="small" condition=loading}}
{{{alreadyPinnedMessage}}}
{{/conditional-loading-spinner}}
+
{{i18n "topic.feature_topic.pin_note"}}
{{/if}}
+
{{{unPinMessage}}}
+
{{d-button action="unpin" icon="thumb-tack" label="topic.feature.unpin" class="btn-primary"}}
{{else}}
-
- {{d-button action="pin" icon="thumb-tack" label="topic.feature.pin" class="btn-primary"}}
-
-
{{{pinMessage}}}
-
{{i18n "topic.feature_topic.pin_note"}}
{{#conditional-loading-spinner size="small" condition=loading}}
{{{alreadyPinnedMessage}}}
{{/conditional-loading-spinner}}
+
+ {{i18n "topic.feature_topic.pin_note"}}
+
+
+ {{{pinMessage}}}
+ {{fa-icon "clock-o"}}
+ {{input type="date" value=model.pinnedInCategoryUntil}}
+
+
+ {{d-button action="pin" icon="thumb-tack" label="topic.feature.pin" class="btn-primary" disabled=pinDisabled}}
+
-
- {{d-button action="pinGlobally" icon="thumb-tack" label="topic.feature.pin_globally" class="btn-primary"}}
-
-
{{i18n "topic.feature_topic.pin_globally"}}
-
{{i18n "topic.feature_topic.global_pin_note"}}
{{#conditional-loading-spinner size="small" condition=loading}}
{{{i18n "topic.feature_topic.already_pinned_globally" count=pinnedGloballyCount}}}
{{/conditional-loading-spinner}}
+
+ {{i18n "topic.feature_topic.global_pin_note"}}
+
+
+ {{i18n "topic.feature_topic.pin_globally"}}
+ {{fa-icon "clock-o"}}
+ {{input type="date" value=model.pinnedGloballyUntil}}
+
+
+ {{d-button action="pinGlobally" icon="thumb-tack" label="topic.feature.pin_globally" class="btn-primary" disabled=pinGloballyDisabled}}
+
{{/if}}
-
- {{#if model.isBanner}}
- {{d-button action="removeBanner" icon="thumb-tack" label="topic.feature.remove_banner" class="btn-primary"}}
- {{else}}
- {{d-button action="makeBanner" icon="thumb-tack" label="topic.feature.make_banner" class="btn-primary"}}
- {{/if}}
-
- {{#if model.isBanner}}
-
{{i18n "topic.feature_topic.remove_banner"}}
- {{else}}
-
{{i18n "topic.feature_topic.make_banner"}}
- {{/if}}
-
{{i18n "topic.feature_topic.banner_note"}}
{{#conditional-loading-spinner size="small" condition=loading}}
{{{i18n "topic.feature_topic.already_banner" count=bannerCount}}}
{{/conditional-loading-spinner}}
+
+ {{i18n "topic.feature_topic.banner_note"}}
+
+
+ {{#if model.isBanner}}
+ {{i18n "topic.feature_topic.remove_banner"}}
+ {{else}}
+ {{i18n "topic.feature_topic.make_banner"}}
+ {{/if}}
+
+
+ {{#if model.isBanner}}
+ {{d-button action="removeBanner" icon="thumb-tack" label="topic.feature.remove_banner" class="btn-primary"}}
+ {{else}}
+ {{d-button action="makeBanner" icon="thumb-tack" label="topic.feature.make_banner" class="btn-primary"}}
+ {{/if}}
+
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