mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
Topic Auto-Close: admins and mods can set a topic to automatically close after a number of days
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
**/
|
||||
Discourse.TopicAdminMenuController = Discourse.ObjectController.extend({
|
||||
visible: false,
|
||||
needs: ['modal'],
|
||||
|
||||
show: function() {
|
||||
this.set('visible', true);
|
||||
@@ -15,6 +16,15 @@ Discourse.TopicAdminMenuController = Discourse.ObjectController.extend({
|
||||
|
||||
hide: function() {
|
||||
this.set('visible', false);
|
||||
},
|
||||
|
||||
autoClose: function() {
|
||||
var modalController = this.get('controllers.modal');
|
||||
if (modalController) {
|
||||
var v = Discourse.EditTopicAutoCloseView.create();
|
||||
v.set('topic', this.get('content'));
|
||||
modalController.show(v);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -65,6 +65,11 @@ Discourse.Composer = Discourse.Model.extend({
|
||||
return false;
|
||||
}.property('editingPost', 'creatingTopic', 'post.post_number'),
|
||||
|
||||
showAdminOptions: function() {
|
||||
if (this.get('creatingTopic') && Discourse.get('currentUser.staff')) return true;
|
||||
return false;
|
||||
}.property('editTitle'),
|
||||
|
||||
togglePreview: function() {
|
||||
this.toggleProperty('showPreview');
|
||||
Discourse.KeyValueStore.set({ key: 'composer.showPreview', value: this.get('showPreview') });
|
||||
@@ -354,7 +359,8 @@ Discourse.Composer = Discourse.Model.extend({
|
||||
actions_summary: Em.A(),
|
||||
moderator: currentUser.get('moderator'),
|
||||
yours: true,
|
||||
newPost: true
|
||||
newPost: true,
|
||||
auto_close_days: this.get('auto_close_days')
|
||||
});
|
||||
|
||||
// If we're in a topic, we can append the post instantly.
|
||||
@@ -532,7 +538,13 @@ Discourse.Composer = Discourse.Model.extend({
|
||||
var reply = this.get('reply') || "";
|
||||
while (Discourse.BBCode.QUOTE_REGEXP.test(reply)) { reply = reply.replace(Discourse.BBCode.QUOTE_REGEXP, ""); }
|
||||
return reply.replace(/\s+/img, " ").trim().length;
|
||||
}.property('reply')
|
||||
}.property('reply'),
|
||||
|
||||
autoCloseChanged: function() {
|
||||
if( this.get('auto_close_days') && this.get('auto_close_days').length > 0 ) {
|
||||
this.set('auto_close_days', this.get('auto_close_days').replace(/[^\d]/g, '') )
|
||||
}
|
||||
}.observes('auto_close_days')
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -168,7 +168,8 @@ Discourse.Post = Discourse.Model.extend({
|
||||
archetype: this.get('archetype'),
|
||||
title: this.get('title'),
|
||||
image_sizes: this.get('imageSizes'),
|
||||
target_usernames: this.get('target_usernames')
|
||||
target_usernames: this.get('target_usernames'),
|
||||
auto_close_days: this.get('auto_close_days')
|
||||
};
|
||||
|
||||
// Put the metaData into the request
|
||||
|
||||
@@ -23,48 +23,60 @@
|
||||
|
||||
{{#if content.viewOpen}}
|
||||
<div class='control-row reply-area'>
|
||||
<div class='reply-to'>{{{content.actionTitle}}}:</div>
|
||||
<div class='reply-to'>{{{content.actionTitle}}}:</div>
|
||||
|
||||
{{#if content.editTitle}}
|
||||
<div class='form-element clearfix'>
|
||||
{{#if content.creatingPrivateMessage}}
|
||||
{{view Discourse.UserSelector topicIdBinding="controller.controllers.topic.content.id" excludeCurrentUser="true" id="private-message-users" class="span8" placeholderKey="composer.users_placeholder" tabindex="1" usernamesBinding="content.targetUsernames"}}
|
||||
{{/if}}
|
||||
{{view Discourse.TextField valueBinding="content.title" tabindex="2" id="reply-title" maxlength="255" class="span8" placeholderKey="composer.title_placeholder"}}
|
||||
{{#unless content.creatingPrivateMessage}}
|
||||
{{view Discourse.ComboboxViewCategory valueAttribute="name" contentBinding="Discourse.site.categories" valueBinding="content.categoryName"}}
|
||||
{{#if content.archetype.hasOptions}}
|
||||
<button class='btn' {{action showOptions target="controller"}}>{{i18n topic.options}}</button>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class='wmd-controls'>
|
||||
<div class='textarea-wrapper'>
|
||||
<div class='wmd-button-bar' id='wmd-button-bar'></div>
|
||||
{{view Discourse.NotifyingTextArea parentBinding="view" tabindex="3" valueBinding="content.reply" id="wmd-input" placeholderKey="composer.reply_placeholder"}}
|
||||
</div>
|
||||
<div class='preview-wrapper'>
|
||||
<div id='wmd-preview' {{bindAttr class="controller.hidePreview:hidden"}}></div>
|
||||
</div>
|
||||
{{#if Discourse.currentUser}}
|
||||
<a href="#" {{action togglePreview target="controller"}} class='toggle-preview'>{{{content.toggleText}}}</a>
|
||||
<div id='draft-status'></div>
|
||||
{{#if view.loadingImage}}
|
||||
<div id="image-uploading">
|
||||
{{i18n image_selector.uploading_image}} {{view.uploadProgress}}% <a id="cancel-image-upload">{{i18n cancel}}</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if content.editTitle}}
|
||||
<div class='form-element clearfix'>
|
||||
{{#if content.creatingPrivateMessage}}
|
||||
{{view Discourse.UserSelector topicIdBinding="controller.controllers.topic.content.id" excludeCurrentUser="true" id="private-message-users" class="span8" placeholderKey="composer.users_placeholder" tabindex="1" usernamesBinding="content.targetUsernames"}}
|
||||
{{/if}}
|
||||
{{view Discourse.TextField valueBinding="content.title" tabindex="2" id="reply-title" maxlength="255" class="span8" placeholderKey="composer.title_placeholder"}}
|
||||
{{#unless content.creatingPrivateMessage}}
|
||||
{{view Discourse.ComboboxViewCategory valueAttribute="name" contentBinding="Discourse.site.categories" valueBinding="content.categoryName"}}
|
||||
{{#if content.archetype.hasOptions}}
|
||||
<button class='btn' {{action showOptions target="controller"}}>{{i18n topic.options}}</button>
|
||||
{{/if}}
|
||||
{{#if content.showAdminOptions}}
|
||||
<button class="btn no-text" {{action toggleAdminOptions target="view"}}><i class="icon icon-wrench"></i></button>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
</div>
|
||||
|
||||
{{#if Discourse.currentUser}}
|
||||
<div class='submit-panel'>
|
||||
<button {{action save target="controller"}} tabindex="4" {{bindAttr disabled="content.cantSubmitPost"}} class='btn btn-primary create'>{{view.content.saveText}}</button>
|
||||
<a href='#' {{action cancel target="controller"}} class='cancel' tabindex="4">{{i18n cancel}}</a>
|
||||
<div class="admin-options-form">
|
||||
<div class="auto-close-fields">
|
||||
<i class="icon icon-time"></i>
|
||||
{{i18n composer.auto_close_label}}
|
||||
{{view Discourse.TextField valueBinding="content.auto_close_days" maxlength="5"}}
|
||||
{{i18n composer.auto_close_units}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class='wmd-controls'>
|
||||
<div class='textarea-wrapper'>
|
||||
<div class='wmd-button-bar' id='wmd-button-bar'></div>
|
||||
{{view Discourse.NotifyingTextArea parentBinding="view" tabindex="3" valueBinding="content.reply" id="wmd-input" placeholderKey="composer.reply_placeholder"}}
|
||||
</div>
|
||||
<div class='preview-wrapper'>
|
||||
<div id='wmd-preview' {{bindAttr class="controller.hidePreview:hidden"}}></div>
|
||||
</div>
|
||||
{{#if Discourse.currentUser}}
|
||||
<a href="#" {{action togglePreview target="controller"}} class='toggle-preview'>{{{content.toggleText}}}</a>
|
||||
<div id='draft-status'></div>
|
||||
{{#if view.loadingImage}}
|
||||
<div id="image-uploading">
|
||||
{{i18n image_selector.uploading_image}} {{view.uploadProgress}}% <a id="cancel-image-upload">{{i18n cancel}}</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if Discourse.currentUser}}
|
||||
<div class='submit-panel'>
|
||||
<button {{action save target="controller"}} tabindex="4" {{bindAttr disabled="content.cantSubmitPost"}} class='btn btn-primary create'>{{view.content.saveText}}</button>
|
||||
<a href='#' {{action cancel target="controller"}} class='cancel' tabindex="4">{{i18n cancel}}</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="modal-body">
|
||||
<form>
|
||||
<div class="auto-close-fields">
|
||||
<i class="icon icon-time"></i>
|
||||
{{i18n composer.auto_close_label}}
|
||||
{{view Discourse.TextField valueBinding="view.auto_close_days" maxlength="5"}}
|
||||
{{i18n composer.auto_close_units}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class='btn btn-primary' {{action saveAutoClose target="view"}} data-dismiss="modal">{{i18n topic.auto_close_save}}</button>
|
||||
<button class='btn' data-dismiss="modal">{{i18n topic.auto_close_cancel}}</button>
|
||||
<button class='btn pull-right' {{action removeAutoClose target="view"}} data-dismiss="modal">{{i18n topic.auto_close_remove}}</button>
|
||||
</div>
|
||||
@@ -72,6 +72,9 @@
|
||||
{{/unless}}
|
||||
{{else}}
|
||||
{{#if view.fullyLoaded}}
|
||||
|
||||
{{view Discourse.TopicClosingView topicBinding="controller.content"}}
|
||||
|
||||
{{view Discourse.TopicFooterButtonsView topicBinding="controller.content"}}
|
||||
|
||||
{{#if controller.content.suggested_topics.length}}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<button {{action toggleClosed}} class='btn btn-admin'><i class='icon-unlock'></i> {{i18n topic.actions.open}}</button>
|
||||
{{else}}
|
||||
<button {{action toggleClosed}} class='btn btn-admin'><i class='icon-lock'></i> {{i18n topic.actions.close}}</button>
|
||||
<button {{action autoClose}} class='btn btn-admin'><i class='icon-time'></i> {{i18n topic.actions.auto_close}}</button>
|
||||
{{/if}}
|
||||
</li>
|
||||
|
||||
@@ -57,5 +58,5 @@
|
||||
</ul>
|
||||
</div>
|
||||
{{else}}
|
||||
<button class='btn' id='show-topic-admin' {{action show}}><i class='icon icon-wrench'></i></button>
|
||||
<button class='btn no-text' id='show-topic-admin' {{action show}}><i class='icon icon-wrench'></i></button>
|
||||
{{/if}}
|
||||
@@ -354,6 +354,19 @@ Discourse.ComposerView = Discourse.View.extend({
|
||||
|
||||
childDidInsertElement: function(e) {
|
||||
return this.initEditor();
|
||||
},
|
||||
|
||||
toggleAdminOptions: function() {
|
||||
var $adminOpts = $('.admin-options-form'),
|
||||
$wmd = $('.wmd-controls'),
|
||||
wmdTop = parseInt($wmd.css('top'),10);
|
||||
if( $adminOpts.is(':visible') ) {
|
||||
$wmd.css('top', wmdTop - parseInt($adminOpts.css('height'),10) + 'px' );
|
||||
$adminOpts.hide();
|
||||
} else {
|
||||
$adminOpts.show();
|
||||
$wmd.css('top', wmdTop + parseInt($adminOpts.css('height'),10) + 'px' );
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
This view handles a modal to set, edit, and remove a topic's auto-close time.
|
||||
|
||||
@class EditTopicAutoCloseView
|
||||
@extends Discourse.ModalBodyView
|
||||
@namespace Discourse
|
||||
@module Discourse
|
||||
**/
|
||||
Discourse.EditTopicAutoCloseView = Discourse.ModalBodyView.extend({
|
||||
templateName: 'modal/auto_close',
|
||||
title: Em.String.i18n('topic.auto_close_title'),
|
||||
modalClass: 'edit-auto-close-modal',
|
||||
|
||||
setDays: function() {
|
||||
if( this.get('topic.auto_close_at') ) {
|
||||
var closeTime = Date.create( this.get('topic.auto_close_at') );
|
||||
if (closeTime.isFuture()) {
|
||||
this.set('auto_close_days', closeTime.daysSince());
|
||||
}
|
||||
}
|
||||
}.observes('topic'),
|
||||
|
||||
saveAutoClose: function() {
|
||||
this.setAutoClose( parseFloat(this.get('auto_close_days')) );
|
||||
},
|
||||
|
||||
removeAutoClose: function() {
|
||||
this.setAutoClose(null);
|
||||
},
|
||||
|
||||
setAutoClose: function(days) {
|
||||
Discourse.ajax({
|
||||
url: "/t/" + this.get('topic.id') + "/autoclose",
|
||||
type: 'PUT',
|
||||
dataType: 'json',
|
||||
data: { auto_close_days: days > 0 ? days : null }
|
||||
}).then(function(){
|
||||
window.location.reload();
|
||||
}, function (error) {
|
||||
bootbox.alert(Em.String.i18n('generic_error'));
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
47
app/assets/javascripts/discourse/views/topic_closing_view.js
Normal file
47
app/assets/javascripts/discourse/views/topic_closing_view.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
This view is used for rendering the notification that a topic will
|
||||
automatically close.
|
||||
|
||||
@class TopicClosingView
|
||||
@extends Ember.ContainerView
|
||||
@namespace Discourse
|
||||
@module Discourse
|
||||
**/
|
||||
Discourse.TopicClosingView = Discourse.View.extend({
|
||||
elementId: 'topic-closing-info',
|
||||
templateName: 'topic_closing',
|
||||
|
||||
render: function(buffer) {
|
||||
if (!this.present('topic.auto_close_at')) return;
|
||||
|
||||
var autoCloseAt = Date.create(this.get('topic.auto_close_at'));
|
||||
|
||||
if (autoCloseAt.isPast()) return;
|
||||
|
||||
var timeLeftString, reRenderDelay, minutesLeft = autoCloseAt.minutesSince();
|
||||
|
||||
if (minutesLeft > 1440) {
|
||||
timeLeftString = Em.String.i18n('in_n_days', {count: autoCloseAt.daysSince()});
|
||||
if( minutesLeft > 2160 ) {
|
||||
reRenderDelay = 12 * 60 * 60000;
|
||||
} else {
|
||||
reRenderDelay = 60 * 60000;
|
||||
}
|
||||
} else if (minutesLeft > 90) {
|
||||
timeLeftString = Em.String.i18n('in_n_hours', {count: autoCloseAt.hoursSince()});
|
||||
reRenderDelay = 30 * 60000;
|
||||
} else if (minutesLeft > 2) {
|
||||
timeLeftString = Em.String.i18n('in_n_minutes', {count: autoCloseAt.minutesSince()});
|
||||
reRenderDelay = 60000;
|
||||
} else {
|
||||
timeLeftString = Em.String.i18n('in_n_seconds', {count: autoCloseAt.secondsSince()});
|
||||
reRenderDelay = 1000;
|
||||
}
|
||||
|
||||
buffer.push('<h3><i class="icon icon-time"></i> ');
|
||||
buffer.push( Em.String.i18n('topic.auto_close_notice', {timeLeft: timeLeftString}) );
|
||||
buffer.push('</h3>');
|
||||
|
||||
this.rerender.bind(this).delay(reRenderDelay);
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
This view is used for rendering the buttons at the footer of the topic
|
||||
|
||||
@class TopicFooterButtonsView
|
||||
@extends Discourse.View
|
||||
@extends Ember.ContainerView
|
||||
@namespace Discourse
|
||||
@module Discourse
|
||||
**/
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
margin: 6px 10px 3px 0;
|
||||
}
|
||||
.wmd-controls {
|
||||
@include transition(top 0.3s ease);
|
||||
top: 100px;
|
||||
}
|
||||
}
|
||||
@@ -365,6 +366,7 @@ div.ac-wrap {
|
||||
|
||||
#reply-control.edit-title.private-message {
|
||||
.wmd-controls {
|
||||
@include transition(top 0.3s ease);
|
||||
top: 140px;
|
||||
}
|
||||
}
|
||||
@@ -466,3 +468,29 @@ div.ac-wrap {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-options-form {
|
||||
margin-top: 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auto-close-fields {
|
||||
input {
|
||||
width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-auto-close-modal {
|
||||
form {
|
||||
margin: 0;
|
||||
}
|
||||
.auto-close-fields {
|
||||
i.icon-time {
|
||||
font-size: 16px;
|
||||
line-height: 8px;
|
||||
}
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,6 @@
|
||||
top: 70px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
|
||||
i {
|
||||
margin: 0px;
|
||||
line-height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-admin-menu {
|
||||
|
||||
@@ -329,6 +329,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#topic-closing-info {
|
||||
margin-left: 103px;
|
||||
}
|
||||
}
|
||||
|
||||
kbd {
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
.icon {
|
||||
margin-right: 7px;
|
||||
}
|
||||
&.no-text {
|
||||
.icon {
|
||||
margin-right: 0;
|
||||
line-height: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default button
|
||||
|
||||
@@ -36,7 +36,8 @@ class PostsController < ApplicationController
|
||||
target_usernames: params[:target_usernames],
|
||||
reply_to_post_number: params[:post][:reply_to_post_number],
|
||||
image_sizes: params[:image_sizes],
|
||||
meta_data: params[:meta_data])
|
||||
meta_data: params[:meta_data],
|
||||
auto_close_days: params[:auto_close_days])
|
||||
post = post_creator.create
|
||||
|
||||
if post_creator.errors.present?
|
||||
|
||||
@@ -15,7 +15,8 @@ class TopicsController < ApplicationController
|
||||
:unmute,
|
||||
:set_notifications,
|
||||
:move_posts,
|
||||
:clear_pin]
|
||||
:clear_pin,
|
||||
:autoclose]
|
||||
|
||||
before_filter :consider_user_for_promotion, only: :show
|
||||
|
||||
@@ -97,6 +98,16 @@ class TopicsController < ApplicationController
|
||||
toggle_mute(false)
|
||||
end
|
||||
|
||||
def autoclose
|
||||
requires_parameter(:auto_close_days)
|
||||
@topic = Topic.where(id: params[:topic_id].to_i).first
|
||||
guardian.ensure_can_moderate!(@topic)
|
||||
@topic.auto_close_days = params[:auto_close_days]
|
||||
@topic.auto_close_user = current_user
|
||||
@topic.save
|
||||
render nothing: true
|
||||
end
|
||||
|
||||
def destroy
|
||||
topic = Topic.where(id: params[:id]).first
|
||||
guardian.ensure_can_delete!(topic)
|
||||
|
||||
@@ -61,6 +61,7 @@ class Topic < ActiveRecord::Base
|
||||
belongs_to :featured_user2, class_name: 'User', foreign_key: :featured_user2_id
|
||||
belongs_to :featured_user3, class_name: 'User', foreign_key: :featured_user3_id
|
||||
belongs_to :featured_user4, class_name: 'User', foreign_key: :featured_user4_id
|
||||
belongs_to :auto_close_user, class_name: 'User', foreign_key: :auto_close_user_id
|
||||
|
||||
has_many :topic_users
|
||||
has_many :topic_links
|
||||
@@ -108,6 +109,18 @@ class Topic < ActiveRecord::Base
|
||||
end
|
||||
end
|
||||
|
||||
before_save do
|
||||
if (auto_close_at_changed? and !auto_close_at_was.nil?) or (auto_close_user_id_changed? and auto_close_at)
|
||||
Jobs.cancel_scheduled_job(:close_topic, {topic_id: id})
|
||||
end
|
||||
end
|
||||
|
||||
after_save do
|
||||
if auto_close_at and (auto_close_at_changed? or auto_close_user_id_changed?)
|
||||
Jobs.enqueue_at(auto_close_at, :close_topic, {topic_id: id, user_id: auto_close_user_id || user_id})
|
||||
end
|
||||
end
|
||||
|
||||
# all users (in groups or directly targetted) that are going to get the pm
|
||||
def all_allowed_users
|
||||
# TODO we should probably change this from 3 queries to 1
|
||||
@@ -264,7 +277,7 @@ class Topic < ActiveRecord::Base
|
||||
update_pinned(status)
|
||||
else
|
||||
# otherwise update the column
|
||||
update_column(property, status)
|
||||
update_column(property == 'autoclosed' ? 'closed' : property, status)
|
||||
end
|
||||
|
||||
key = "topic_statuses.#{property}_"
|
||||
@@ -273,9 +286,11 @@ class Topic < ActiveRecord::Base
|
||||
opts = {}
|
||||
|
||||
# We don't bump moderator posts except for the re-open post.
|
||||
opts[:bump] = true if property == 'closed' and (!status)
|
||||
opts[:bump] = true if (property == 'closed' or property == 'autoclosed') and (!status)
|
||||
|
||||
add_moderator_post(user, I18n.t(key), opts)
|
||||
message = property != 'autoclosed' ? I18n.t(key) : I18n.t(key, count: (((self.auto_close_at||Time.zone.now) - self.created_at) / 86_400).round )
|
||||
|
||||
add_moderator_post(user, message, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -712,4 +727,9 @@ class Topic < ActiveRecord::Base
|
||||
def notify_muted!(user)
|
||||
TopicUser.change(user, id, notification_level: TopicUser.notification_levels[:muted])
|
||||
end
|
||||
|
||||
def auto_close_days=(num_days)
|
||||
self.auto_close_at = (num_days and num_days.to_i > 0.0 ? num_days.to_i.days.from_now : nil)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -18,7 +18,8 @@ class TopicViewSerializer < ApplicationSerializer
|
||||
:moderator_posts_count,
|
||||
:has_best_of,
|
||||
:archetype,
|
||||
:slug]
|
||||
:slug,
|
||||
:auto_close_at]
|
||||
end
|
||||
|
||||
def self.guardian_attributes
|
||||
|
||||
Reference in New Issue
Block a user