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.user}}{{#link-to 'adminUser' flaggedPost.user}}{{avatar flaggedPost.user imageSize="small"}}{{/link-to}}{{/if}} + + {{#if flaggedPost.postAuthorFlagged}} + {{#if flaggedPost.user}} + {{#link-to 'adminUser' flaggedPost.user}}{{avatar flaggedPost.user imageSize="small"}}{{/link-to}} + {{/if}} + {{/if}} + - {{#if flaggedPost.topicHidden}} {{/if}}

{{flaggedPost.title}}


{{{flaggedPost.excerpt}}} + + {{#if flaggedPost.topicHidden}} {{/if}}

{{flaggedPost.title}}

+
+ {{#if flaggedPost.postAuthorFlagged}} + {{{flaggedPost.excerpt}}} + {{/if}} @@ -51,6 +62,15 @@ + {{#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