diff --git a/app/assets/javascripts/admin/controllers/admin_flags_controller.js b/app/assets/javascripts/admin/controllers/admin_flags_controller.js
index 77ca65d793f..c369ec8ea21 100644
--- a/app/assets/javascripts/admin/controllers/admin_flags_controller.js
+++ b/app/assets/javascripts/admin/controllers/admin_flags_controller.js
@@ -42,6 +42,10 @@ Discourse.AdminFlagsController = Ember.ArrayController.extend({
});
},
+ doneTopicFlags: function(item) {
+ this.send('disagreeFlags', item);
+ },
+
/**
Deletes a post
diff --git a/app/assets/javascripts/admin/models/flagged_post.js b/app/assets/javascripts/admin/models/flagged_post.js
index 5fadbb69f28..c56e132189d 100644
--- a/app/assets/javascripts/admin/models/flagged_post.js
+++ b/app/assets/javascripts/admin/models/flagged_post.js
@@ -62,6 +62,14 @@ Discourse.FlaggedPost = Discourse.Post.extend({
return !_.every(this.get('post_actions'), function(action) { return action.name_key !== 'spam'; });
}.property('post_actions.@each.name_key'),
+ topicFlagged: function() {
+ return _.any(this.get('post_actions'), function(action) { return action.targets_topic; });
+ }.property('post_actions.@each.targets_topic'),
+
+ postAuthorFlagged: function() {
+ return _.any(this.get('post_actions'), function(action) { return !action.targets_topic; });
+ }.property('post_actions.@each.targets_topic'),
+
canDeleteAsSpammer: function() {
return (Discourse.User.currentProp('staff') && this.get('flaggedForSpam') && this.get('user.can_delete_all_posts') && this.get('user.can_be_deleted'));
}.property('flaggedForSpam'),
diff --git a/app/assets/javascripts/admin/templates/flags.js.handlebars b/app/assets/javascripts/admin/templates/flags.js.handlebars
index 798d5fae00e..eb5a12ef938 100644
--- a/app/assets/javascripts/admin/templates/flags.js.handlebars
+++ b/app/assets/javascripts/admin/templates/flags.js.handlebars
@@ -26,9 +26,20 @@
{{#each flaggedPost in content}}
+ {{#if flaggedPost.topicFlagged}}
+
+ |
+ {{{i18n admin.flags.topic_flagged}}} |
+ |
+ |
+
+ {{/if}}
+
{{#each flaggedPost.messages}}
|
@@ -74,19 +94,27 @@
{{#if adminActiveFlagsView}}
- {{#if flaggedPost.postHidden}}
-
-
+ {{#if flaggedPost.topicFlagged}}
+ {{i18n admin.flags.visit_topic}}
+ {{/if}}
+
+ {{#if flaggedPost.postAuthorFlagged}}
+ {{#if flaggedPost.postHidden}}
+
+
+ {{else}}
+
+
+ {{/if}}
+
+ {{#if flaggedPost.canDeleteAsSpammer}}
+
+ {{/if}}
+
+
{{else}}
-
-
+
{{/if}}
-
- {{#if flaggedPost.canDeleteAsSpammer}}
-
- {{/if}}
-
-
{{/if}}
|
diff --git a/app/assets/javascripts/discourse/controllers/flag_controller.js b/app/assets/javascripts/discourse/controllers/flag_controller.js
index b88660bf999..f926b070c84 100644
--- a/app/assets/javascripts/discourse/controllers/flag_controller.js
+++ b/app/assets/javascripts/discourse/controllers/flag_controller.js
@@ -13,6 +13,30 @@ Discourse.FlagController = Discourse.ObjectController.extend(Discourse.ModalFunc
this.set('selected', null);
},
+ flagsAvailable: function() {
+ if (!this.get('flagTopic')) {
+ return this.get('model.flagsAvailable');
+ } else {
+ var self = this,
+ lookup = Em.Object.create();
+
+ _.each(this.get("actions_summary"),function(a) {
+ var actionSummary;
+ a.flagTopic = self.get('model');
+ a.actionType = Discourse.Site.current().topicFlagTypeById(a.id);
+ actionSummary = Discourse.ActionSummary.create(a);
+ lookup.set(a.actionType.get('name_key'), actionSummary);
+ });
+ this.set('topicActionByName', lookup);
+
+ return Discourse.Site.currentProp('topic_flag_types').filter(function(item) {
+ return _.any(self.get("actions_summary"), function(a) {
+ return (a.id === item.get('id') && a.can_act);
+ });
+ });
+ }
+ }.property('post', 'flagTopic', 'actions_summary.@each.can_act'),
+
submitEnabled: function() {
var selected = this.get('selected');
if (!selected) return false;
@@ -29,6 +53,8 @@ Discourse.FlagController = Discourse.ObjectController.extend(Discourse.ModalFunc
// Staff accounts can "take action"
canTakeAction: function() {
+ if (this.get("flagTopic")) return false;
+
// We can only take actions on non-custom flags
if (this.get('selected.is_custom_flag')) return false;
return Discourse.User.currentProp('staff');
@@ -36,9 +62,9 @@ Discourse.FlagController = Discourse.ObjectController.extend(Discourse.ModalFunc
submitText: function(){
if (this.get('selected.is_custom_flag')) {
- return I18n.t("flagging.notify_action");
+ return I18n.t(this.get('flagTopic') ? "flagging_topic.notify_action" : "flagging.notify_action");
} else {
- return I18n.t("flagging.action");
+ return I18n.t(this.get('flagTopic') ? "flagging_topic.action" : "flagging.action");
}
}.property('selected.is_custom_flag'),
@@ -50,7 +76,12 @@ Discourse.FlagController = Discourse.ObjectController.extend(Discourse.ModalFunc
createFlag: function(opts) {
var self = this;
- var postAction = this.get('actionByName.' + this.get('selected.name_key'));
+ var postAction; // an instance of ActionSummary
+ if (!this.get('flagTopic')) {
+ postAction = this.get('actionByName.' + this.get('selected.name_key'));
+ } else {
+ postAction = this.get('topicActionByName.' + this.get('selected.name_key'));
+ }
var params = this.get('selected.is_custom_flag') ? {message: this.get('message')} : {};
if (opts) params = $.extend(params, opts);
@@ -58,6 +89,7 @@ Discourse.FlagController = Discourse.ObjectController.extend(Discourse.ModalFunc
this.send('hideModal');
postAction.act(params).then(function() {
self.send('closeModal');
+ if (self.get('flagTopic')) { bootbox.alert(I18n.t('topic.flag_topic.success_message')); }
}, function(errors) {
self.send('showModal');
self.displayErrors(errors);
@@ -70,6 +102,8 @@ Discourse.FlagController = Discourse.ObjectController.extend(Discourse.ModalFunc
},
canDeleteSpammer: function() {
+ if (this.get("flagTopic")) return false;
+
if (Discourse.User.currentProp('staff') && this.get('selected.name_key') === 'spam') {
return this.get('userDetails.can_be_deleted') && this.get('userDetails.can_delete_all_posts');
} else {
diff --git a/app/assets/javascripts/discourse/models/action_summary.js b/app/assets/javascripts/discourse/models/action_summary.js
index 926f1cb2c98..4fbb7a7d10e 100644
--- a/app/assets/javascripts/discourse/models/action_summary.js
+++ b/app/assets/javascripts/discourse/models/action_summary.js
@@ -70,10 +70,11 @@ Discourse.ActionSummary = Discourse.Model.extend({
return Discourse.ajax("/post_actions", {
type: 'POST',
data: {
- id: this.get('post.id'),
+ id: this.get('flagTopic') ? this.get('flagTopic.id') : this.get('post.id'),
post_action_type_id: this.get('id'),
message: opts.message,
- take_action: opts.takeAction
+ take_action: opts.takeAction,
+ flag_topic: this.get('flagTopic') ? true : false
}
}).then(null, function (error) {
actionSummary.removeAction();
diff --git a/app/assets/javascripts/discourse/models/site.js b/app/assets/javascripts/discourse/models/site.js
index 52ae7ca8dce..7512a717bfe 100644
--- a/app/assets/javascripts/discourse/models/site.js
+++ b/app/assets/javascripts/discourse/models/site.js
@@ -30,6 +30,10 @@ Discourse.Site = Discourse.Model.extend({
return this.get("postActionByIdLookup.action" + id);
},
+ topicFlagTypeById: function(id) {
+ return this.get("topicFlagByIdLookup.action" + id);
+ },
+
updateCategory: function(newCategory) {
var existingCategory = this.get('categories').findProperty('id', Em.get(newCategory, 'id'));
if (existingCategory) existingCategory.mergeAttributes(newCategory);
@@ -82,6 +86,16 @@ Discourse.Site.reopenClass(Discourse.Singleton, {
return actionType;
});
}
+
+ if (result.topic_flag_types) {
+ result.topicFlagByIdLookup = Em.Object.create();
+ result.topic_flag_types = _.map(result.topic_flag_types,function(p) {
+ var actionType = Discourse.PostActionType.create(p);
+ result.topicFlagByIdLookup.set("action" + p.id, actionType);
+ return actionType;
+ });
+ }
+
if (result.archetypes) {
result.archetypes = _.map(result.archetypes,function(a) {
return Discourse.Archetype.create(a);
diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js
index dc9145c8241..d46f1eb1d79 100644
--- a/app/assets/javascripts/discourse/models/topic.js
+++ b/app/assets/javascripts/discourse/models/topic.js
@@ -316,6 +316,26 @@ Discourse.Topic.reopenClass({
MUTE: 0
},
+ createActionSummary: function(result) {
+ if (result.actions_summary) {
+ var lookup = Em.Object.create();
+ result.actions_summary = result.actions_summary.map(function(a) {
+ a.post = result;
+ a.actionType = Discourse.Site.current().postActionTypeById(a.id);
+ var actionSummary = Discourse.ActionSummary.create(a);
+ lookup.set(a.actionType.get('name_key'), actionSummary);
+ return actionSummary;
+ });
+ result.set('actionByName', lookup);
+ }
+ },
+
+ create: function() {
+ var result = this._super.apply(this, arguments);
+ this.createActionSummary(result);
+ return result;
+ },
+
/**
Find similar topics to a given title and body
diff --git a/app/assets/javascripts/discourse/routes/topic_route.js b/app/assets/javascripts/discourse/routes/topic_route.js
index 4ebb3510129..d7e165c352b 100644
--- a/app/assets/javascripts/discourse/routes/topic_route.js
+++ b/app/assets/javascripts/discourse/routes/topic_route.js
@@ -27,6 +27,12 @@ Discourse.TopicRoute = Discourse.Route.extend({
this.controllerFor('flag').setProperties({ selected: null });
},
+ showFlagTopic: function(topic) {
+ //Discourse.Route.showModal(this, 'flagTopic', topic);
+ Discourse.Route.showModal(this, 'flag', topic);
+ this.controllerFor('flag').setProperties({ selected: null, flagTopic: true });
+ },
+
showAutoClose: function() {
Discourse.Route.showModal(this, 'editTopicAutoClose', this.modelFor('topic'));
this.controllerFor('modal').set('modalClass', 'edit-auto-close-modal');
diff --git a/app/assets/javascripts/discourse/views/buttons/flag_topic_button.js b/app/assets/javascripts/discourse/views/buttons/flag_topic_button.js
new file mode 100644
index 00000000000..7c92385ec37
--- /dev/null
+++ b/app/assets/javascripts/discourse/views/buttons/flag_topic_button.js
@@ -0,0 +1,22 @@
+/**
+ A button for flagging a topic
+
+ @class FlagTopicButton
+ @extends Discourse.ButtonView
+ @namespace Discourse
+ @module Discourse
+**/
+Discourse.FlagTopicButton = Discourse.ButtonView.extend({
+ classNames: ['flag-topic'],
+ textKey: 'topic.flag_topic.title',
+ helpKey: 'topic.flag_topic.help',
+
+ click: function() {
+ this.get('controller').send('showFlagTopic', this.get('controller.content'));
+ },
+
+ renderIcon: function(buffer) {
+ buffer.push("");
+ }
+});
+
diff --git a/app/assets/javascripts/discourse/views/modal/flag_view.js b/app/assets/javascripts/discourse/views/modal/flag_view.js
index 5ec713e9a03..8170cdfc581 100644
--- a/app/assets/javascripts/discourse/views/modal/flag_view.js
+++ b/app/assets/javascripts/discourse/views/modal/flag_view.js
@@ -8,7 +8,10 @@
**/
Discourse.FlagView = Discourse.ModalBodyView.extend({
templateName: 'modal/flag',
- title: I18n.t('flagging.title'),
+
+ title: function() {
+ return this.get('controller.flagTopic') ? I18n.t('flagging_topic.title') : I18n.t('flagging.title');
+ }.property('controller.flagTopic'),
selectedChanged: function() {
var flagView = this;
diff --git a/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js b/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js
index c1f62250635..53c9dfe9c4d 100644
--- a/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js
+++ b/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js
@@ -20,7 +20,6 @@ Discourse.TopicFooterButtonsView = Discourse.ContainerView.extend({
var topic = this.get('topic');
if (Discourse.User.current()) {
if (!topic.get('isPrivateMessage')) {
-
// We hide some controls from private messages
if (this.get('topic.details.can_invite_to')) {
this.attachViewClass(Discourse.InviteReplyButton);
@@ -28,6 +27,9 @@ Discourse.TopicFooterButtonsView = Discourse.ContainerView.extend({
this.attachViewClass(Discourse.StarButton);
this.attachViewClass(Discourse.ShareButton);
this.attachViewClass(Discourse.ClearPinButton);
+ if (this.get('topic.details.can_flag_topic')) {
+ this.attachViewClass(Discourse.FlagTopicButton);
+ }
}
this.attachViewClass(Discourse.ReplyButton);
this.attachViewClass(Discourse.NotificationsButton);
diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js
index 71a6997e5b9..273d9b88ea8 100644
--- a/app/assets/javascripts/main_include.js
+++ b/app/assets/javascripts/main_include.js
@@ -19,6 +19,7 @@
//= require ./discourse/controllers/controller
//= require ./discourse/controllers/object_controller
//= require ./discourse/views/modal/modal_body_view
+//= require ./discourse/views/modal/flag_view
//= require ./discourse/views/combobox_view
//= require ./discourse/views/buttons/button_view
//= require ./discourse/views/buttons/dropdown_button_view
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index d754e81e7df..6c9760e1c5c 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -369,11 +369,11 @@ table {
}
td { vertical-align: top; }
th { text-align: left; }
- .user { width: 40px; }
+ .user { width: 40px; padding-top: 12px; }
.excerpt {
max-width: 740px;
width: 740px;
- padding: 0 10px 10px 0;
+ padding: 8px;
word-wrap: break-word;
.fa,h3 { display: inline-block; }
diff --git a/app/controllers/post_actions_controller.rb b/app/controllers/post_actions_controller.rb
index b62f2a1b468..20f897b317b 100644
--- a/app/controllers/post_actions_controller.rb
+++ b/app/controllers/post_actions_controller.rb
@@ -12,6 +12,7 @@ class PostActionsController < ApplicationController
args = {}
args[:message] = params[:message] if params[:message].present?
args[:take_action] = true if guardian.is_staff? and params[:take_action] == 'true'
+ args[:flag_topic] = true if params[:flag_topic]
post_action = PostAction.act(current_user, @post, @post_action_type_id, args)
@@ -63,7 +64,18 @@ class PostActionsController < ApplicationController
def fetch_post_from_params
params.require(:id)
- finder = Post.where(id: params[:id])
+
+ post_id = if params[:flag_topic]
+ begin
+ Topic.find(params[:id]).posts.first.id
+ rescue
+ raise Discourse::NotFound
+ end
+ else
+ params[:id]
+ end
+
+ finder = Post.where(id: post_id)
# Include deleted posts if the user is a moderator (to guardian ?)
finder = finder.with_deleted if current_user.try(:moderator?)
diff --git a/app/models/post_action.rb b/app/models/post_action.rb
index 009b1321c64..9f15a7f31a6 100644
--- a/app/models/post_action.rb
+++ b/app/models/post_action.rb
@@ -12,6 +12,7 @@ class PostAction < ActiveRecord::Base
belongs_to :user
belongs_to :post_action_type
belongs_to :related_post, class_name: 'Post'
+ belongs_to :target_user, class_name: 'User'
rate_limit :post_action_rate_limiter
@@ -128,7 +129,8 @@ class PostAction < ActiveRecord::Base
post_action_type_id: post_action_type_id,
message: opts[:message],
staff_took_action: opts[:take_action] || false,
- related_post_id: related_post_id )
+ related_post_id: related_post_id,
+ targets_topic: !!opts[:flag_topic] )
rescue ActiveRecord::RecordNotUnique
# can happen despite being .create
# since already bookmarked
@@ -319,6 +321,7 @@ end
# staff_took_action :boolean default(FALSE), not null
# defer :boolean
# defer_by :integer
+# targets_topic :boolean default(FALSE)
#
# Indexes
#
diff --git a/app/models/post_action_type.rb b/app/models/post_action_type.rb
index a8519a3289f..ca8d5dbaf32 100644
--- a/app/models/post_action_type.rb
+++ b/app/models/post_action_type.rb
@@ -28,6 +28,10 @@ class PostActionType < ActiveRecord::Base
@notify_flag_type_ids ||= types.only(:off_topic, :spam, :inappropriate, :notify_moderators).values
end
+ def topic_flag_types
+ @topic_flag_types ||= types.only(:spam, :inappropriate, :notify_moderators)
+ end
+
def is_flag?(sym)
flag_types.valid?(sym)
end
diff --git a/app/models/site.rb b/app/models/site.rb
index 8ae34381211..fe2c4c8a336 100644
--- a/app/models/site.rb
+++ b/app/models/site.rb
@@ -17,6 +17,10 @@ class Site
PostActionType.ordered
end
+ def topic_flag_types
+ post_action_types.where(name_key: ['inappropriate', 'spam', 'notify_moderators'])
+ end
+
def notification_types
Notification.types
end
diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb
index fdfe2e0f877..c3953a8da09 100644
--- a/app/serializers/site_serializer.rb
+++ b/app/serializers/site_serializer.rb
@@ -12,6 +12,7 @@ class SiteSerializer < ApplicationSerializer
has_many :categories, serializer: BasicCategorySerializer, embed: :objects
has_many :post_action_types, embed: :objects
+ has_many :topic_flag_types, serializer: TopicFlagTypeSerializer, embed: :objects
has_many :trust_levels, embed: :objects
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
@@ -31,7 +32,7 @@ class SiteSerializer < ApplicationSerializer
def periods
TopTopic.periods.map(&:to_s)
end
-
+
def top_menu_items
Discourse.top_menu_items.map(&:to_s)
end
diff --git a/app/serializers/topic_flag_type_serializer.rb b/app/serializers/topic_flag_type_serializer.rb
new file mode 100644
index 00000000000..f95cf6a8d97
--- /dev/null
+++ b/app/serializers/topic_flag_type_serializer.rb
@@ -0,0 +1,9 @@
+class TopicFlagTypeSerializer < PostActionTypeSerializer
+
+ protected
+
+ def i18n(field, vars={})
+ I18n.t("topic_flag_types.#{object.name_key}.#{field}", vars)
+ end
+
+end
diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb
index f0c895907fd..e3d0ed79297 100644
--- a/app/serializers/topic_view_serializer.rb
+++ b/app/serializers/topic_view_serializer.rb
@@ -35,7 +35,8 @@ class TopicViewSerializer < ApplicationSerializer
:details,
:highest_post_number,
:last_read_post_number,
- :deleted_by
+ :deleted_by,
+ :actions_summary
# Define a delegator for each attribute of the topic we want
attributes *topic_attributes
@@ -97,6 +98,7 @@ class TopicViewSerializer < ApplicationSerializer
result[:can_invite_to] = true if scope.can_invite_to?(object.topic)
result[:can_create_post] = true if scope.can_create?(Post, object.topic)
result[:can_reply_as_new_topic] = true if scope.can_reply_as_new_topic?(object.topic)
+ result[:can_flag_topic] = actions_summary.any? { |a| a[:can_act] }
result
end
@@ -144,5 +146,17 @@ class TopicViewSerializer < ApplicationSerializer
PinnedCheck.new(object.topic, object.topic_user).pinned?
end
+ def actions_summary
+ result = []
+ return [] unless post = object.posts.try(:first)
+ PostActionType.topic_flag_types.each do |sym, id|
+ result << { id: id,
+ count: 0,
+ hidden: false,
+ can_act: scope.post_can_act?(post, sym)}
+ # TODO: other keys? :can_clear_flags, :acted, :can_undo
+ end
+ result
+ end
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index e94c3ffa066..8a05f493daa 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -788,6 +788,11 @@ en:
title: 'Share'
help: 'share a link to this topic'
+ flag_topic:
+ title: 'Flag'
+ help: 'privately flag this topic for attention or send a private notification about it'
+ success_message: 'You successfully flagged this topic.'
+
inviting: "Inviting..."
invite_private:
@@ -1077,6 +1082,11 @@ en:
more: "{{n}} to go..."
left: "{{n}} remaining"
+ flagging_topic:
+ title: "Why are you privately flagging this topic?"
+ action: "Flag Topic"
+ notify_action: "Notify"
+
topic_map:
title: "Topic Summary"
links_shown: "show all {{totalLinks}} links..."
@@ -1239,12 +1249,16 @@ en:
disagree: "Disagree"
disagree_title: "Disagree with flag, remove any flags from this post"
delete_spammer_title: "Delete the user and all its posts and topics."
+ clear_topic_flags: "Done"
+ clear_topic_flags_title: "The topic has been investigated and issues have been resolved. Click Done to remove the flags."
flagged_by: "Flagged by"
system: "System"
error: "Something went wrong"
view_message: "Reply"
no_results: "There are no flags."
+ topic_flagged: "This topic has been flagged."
+ visit_topic: "Visit the topic to investigate and take action."
summary:
action_type_3:
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 0987b6a3f7f..cc63edff0df 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -360,6 +360,22 @@ en:
description: 'Vote for this post'
long_form: 'voted for this post'
+ topic_flag_types:
+ spam:
+ title: 'Spam'
+ description: 'This topic is an advertisement. It is not useful or relevant to this site, but promotional in nature.'
+ long_form: 'flagged this as spam'
+ inappropriate:
+ title: 'Inappropriate'
+ description: 'This topic contains content that a reasonable person would consider offensive, abusive, or a violation of our community guidelines.'
+ long_form: 'flagged this as inappropriate'
+ notify_moderators:
+ title: 'Notify moderators'
+ description: 'This topic requires general moderator attention based on the FAQ, TOS, or for another reason not listed above.'
+ long_form: 'notified moderators'
+ email_title: 'The topic "%{title}" requires moderator attention'
+ email_body: "%{link}\n\n%{message}"
+
flagging:
you_must_edit: 'Your post was flagged by the community. Please see your private messages.
'
user_must_edit: 'Flagged content temporarily hidden.
'
diff --git a/db/migrate/20140211234523_add_targets_topic_to_post_actions.rb b/db/migrate/20140211234523_add_targets_topic_to_post_actions.rb
new file mode 100644
index 00000000000..38355175b4d
--- /dev/null
+++ b/db/migrate/20140211234523_add_targets_topic_to_post_actions.rb
@@ -0,0 +1,5 @@
+class AddTargetsTopicToPostActions < ActiveRecord::Migration
+ def change
+ add_column :post_actions, :targets_topic, :boolean, default: false
+ end
+end