From 5af0f5f80ea5f03c75a02e18a40d56e4e4bad47c Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 10 Sep 2015 16:01:23 -0400 Subject: [PATCH] FEATURE: Whisper posts --- .../discourse/controllers/composer.js.es6 | 7 +++- .../discourse/models/composer.js.es6 | 6 +++- .../javascripts/discourse/models/post.js.es6 | 3 +- .../discourse/templates/composer.hbs | 10 ++++++ .../javascripts/discourse/templates/post.hbs | 13 ++++--- .../javascripts/discourse/views/post.js.es6 | 12 ++++++- .../stylesheets/common/base/topic-post.scss | 2 +- .../stylesheets/desktop/topic-post.scss | 9 +++++ app/controllers/posts_controller.rb | 4 +++ app/models/post.rb | 27 ++++++++++----- app/models/topic.rb | 7 ++++ config/locales/client.en.yml | 2 ++ config/locales/server.en.yml | 1 + config/site_settings.yml | 3 ++ lib/guardian/post_guardian.rb | 11 +++--- lib/topic_view.rb | 21 ++++++++---- spec/components/guardian_spec.rb | 26 ++++++++++++++ spec/components/topic_view_spec.rb | 17 ++++++++++ spec/models/topic_spec.rb | 34 +++++++++++++++++++ 19 files changed, 186 insertions(+), 29 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 843aae4ee9c..d6d1224f53e 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -3,6 +3,7 @@ import DiscourseURL from 'discourse/lib/url'; import Quote from 'discourse/lib/quote'; import Draft from 'discourse/models/draft'; import Composer from 'discourse/models/composer'; +import computed from 'ember-addons/ember-computed-decorators'; function loadDraft(store, opts) { opts = opts || {}; @@ -64,6 +65,11 @@ export default Ember.Controller.extend({ this.set('similarTopics', []); }.on('init'), + @computed('model.action') + canWhisper(action) { + return this.siteSettings.enable_whispers && action === Composer.REPLY; + }, + showWarning: function() { if (!Discourse.User.currentProp('staff')) { return false; } @@ -132,7 +138,6 @@ export default Ember.Controller.extend({ }, hitEsc() { - const messages = this.get('controllers.composer-messages.model'); if (messages.length) { messages.popObject(); diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 9cb5db0693d..576bec3973a 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -24,6 +24,7 @@ const CLOSED = 'closed', category: 'categoryId', topic_id: 'topic.id', is_warning: 'isWarning', + whisper: 'whisper', archetype: 'archetypeId', target_usernames: 'targetUsernames', typing_duration_msecs: 'typingTime', @@ -557,6 +558,9 @@ const Composer = RestModel.extend({ let addedToStream = false; + const postTypes = this.site.get('post_types'); + const postType = this.get('whisper') ? postTypes.whisper : postTypes.regular; + // Build the post object const createdPost = this.store.createRecord('post', { imageSizes: opts.imageSizes, @@ -569,7 +573,7 @@ const Composer = RestModel.extend({ user_title: user.get('title'), avatar_template: user.get('avatar_template'), user_custom_fields: user.get('custom_fields'), - post_type: this.site.get('post_types.regular'), + post_type: postType, actions_summary: [], moderator: user.get('moderator'), admin: user.get('admin'), diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index c332836f67f..6ba2606aa60 100644 --- a/app/assets/javascripts/discourse/models/post.js.es6 +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -1,7 +1,7 @@ import RestModel from 'discourse/models/rest'; import { popupAjaxError } from 'discourse/lib/ajax-error'; import ActionSummary from 'discourse/models/action-summary'; -import { url, fmt, propertyEqual } from 'discourse/lib/computed'; +import { url, propertyEqual } from 'discourse/lib/computed'; import Quote from 'discourse/lib/quote'; import computed from 'ember-addons/ember-computed-decorators'; @@ -77,7 +77,6 @@ const Post = RestModel.extend({ topicOwner: propertyEqual('topic.details.created_by.id', 'user_id'), hasHistory: Em.computed.gt('version', 1), - postElementId: fmt('post_number', 'post_%@'), canViewRawEmail: function() { return this.get("user_id") === Discourse.User.currentProp("id") || Discourse.User.currentProp('staff'); diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index 711996431fa..362df127919 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -60,6 +60,16 @@ {{/unless}} {{/if}} + + {{#if canWhisper}} +
+ +
+ {{/if}} + {{plugin-outlet "composer-fields"}} diff --git a/app/assets/javascripts/discourse/templates/post.hbs b/app/assets/javascripts/discourse/templates/post.hbs index 29b95d5aa28..8beccb2b663 100644 --- a/app/assets/javascripts/discourse/templates/post.hbs +++ b/app/assets/javascripts/discourse/templates/post.hbs @@ -8,7 +8,7 @@ {{view 'reply-history' content=replyHistory}} -
+
@@ -45,15 +45,20 @@
{{/if}} {{#if wiki}} - + {{/if}} {{#if via_email}} {{#if canViewRawEmail}} - + {{else}} - + {{/if}} {{/if}} + + {{#if view.whisper}} + + {{/if}} + {{#if showUserReplyTab}} {{#if loadingReplyHistory}} diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6 index ef1869e90a5..e2c66317753 100644 --- a/app/assets/javascripts/discourse/views/post.js.es6 +++ b/app/assets/javascripts/discourse/views/post.js.es6 @@ -1,6 +1,8 @@ import ScreenTrack from 'discourse/lib/screen-track'; import { number } from 'discourse/lib/formatter'; import DiscourseURL from 'discourse/lib/url'; +import computed from 'ember-addons/ember-computed-decorators'; +import { fmt } from 'discourse/lib/computed'; const DAY = 60 * 50 * 1000; @@ -12,10 +14,18 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, { 'post.deleted:deleted', 'post.topicOwner:topic-owner', 'groupNameClass', - 'post.wiki:wiki'], + 'post.wiki:wiki', + 'whisper'], post: Ember.computed.alias('content'), + postElementId: fmt('post.post_number', 'post_%@'), + + @computed('post.post_type') + whisper(postType) { + return postType === this.site.get('post_types.whisper'); + }, + templateName: function() { return (this.get('post.post_type') === this.site.get('post_types.small_action')) ? 'post-small-action' : 'post'; }.property('post.post_type'), diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 3b0d6200875..d11b4ac3a6f 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -147,7 +147,7 @@ aside.quote { } .post-info { - &.wiki, &.via-email { + &.wiki, &.via-email, &.whisper { margin-right: 5px; i.fa { font-size: 1em; diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index 41409878ff5..84730deccf1 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -582,6 +582,15 @@ a.mention { } } +.whisper { + .topic-body { + .cooked { + font-style: italic; + color: dark-light-diff($primary, $secondary, 55%, -40%); + } + } +} + #share-link { width: 365px; margin-left: -4px; diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index a35e2f423e2..5ee72c315ac 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -465,6 +465,10 @@ class PostsController < ApplicationController result[:is_warning] = false end + if SiteSetting.enable_whispers? && params[:whisper] == "true" + result[:post_type] = Post.types[:whisper] + end + PostRevisor.tracked_topic_fields.each_key do |f| params.permit(f => []) result[f] = params[f] if params.has_key?(f) diff --git a/app/models/post.rb b/app/models/post.rb index 3a6a8d51591..3cf1fb367d2 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -74,7 +74,7 @@ class Post < ActiveRecord::Base end def self.types - @types ||= Enum.new(:regular, :moderator_action, :small_action) + @types ||= Enum.new(:regular, :moderator_action, :small_action, :whisper) end def self.cook_methods @@ -96,15 +96,24 @@ class Post < ActiveRecord::Base end def publish_change_to_clients!(type) - # special failsafe for posts missing topics - # consistency checks should fix, but message + + channel = "/topic/#{topic_id}" + msg = { id: id, + post_number: post_number, + updated_at: Time.now, + type: type } + + # special failsafe for posts missing topics consistency checks should fix, but message # is safe to skip - MessageBus.publish("/topic/#{topic_id}", { - id: id, - post_number: post_number, - updated_at: Time.now, - type: type - }, group_ids: topic.secure_group_ids) if topic + return unless topic + + # Whispers should not be published to everyone + if post_type == Post.types[:whisper] + user_ids = User.where('admin or moderator or id = ?', user_id).pluck(:id) + MessageBus.publish(channel, msg, user_ids: user_ids) + else + MessageBus.publish(channel, msg, group_ids: topic.secure_group_ids) + end end def trash!(trashed_by=nil) diff --git a/app/models/topic.rb b/app/models/topic.rb index 442fb43c2d1..703b547265f 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -218,6 +218,13 @@ class Topic < ActiveRecord::Base end end + def visible_post_types(viewed_by=nil) + types = Post.types + result = [types[:regular], types[:moderator_action], types[:small_action]] + result << types[:whisper] if viewed_by.try(:staff?) + result + end + def self.top_viewed(max = 10) Topic.listable_topics.visible.secured.order('views desc').limit(max) end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index bbc1833f034..881c30a5710 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -809,6 +809,7 @@ en: emoji: "Emoji :smile:" add_warning: "This is an official warning." + add_whisper: "This is a whisper only visible to moderators" posting_not_on_topic: "Which topic do you want to reply to?" saving_draft_tip: "saving..." saved_draft_tip: "saved" @@ -1349,6 +1350,7 @@ en: yes_value: "Yes, abandon" via_email: "this post arrived via email" + whisper: "this post is a private whisper for moderators" wiki: about: "this post is a wiki; basic users can edit it" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 1309a124ed5..1da6daf5bb0 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -880,6 +880,7 @@ en: email_token_grace_period_hours: "Forgot password / activate account tokens are still valid for a grace period of (n) hours after being redeemed." enable_badges: "Enable the badge system" + enable_whispers: "Allow users to whisper to moderators" allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines." email_domains_blacklist: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net" diff --git a/config/site_settings.yml b/config/site_settings.yml index 70992a968de..fb6adceedf2 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -182,6 +182,9 @@ basic: enable_badges: client: true default: true + enable_whispers: + client: true + default: false login: invite_only: diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index e3f1a030b8b..5b03eff43a3 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -144,10 +144,13 @@ module PostGuardian end def can_see_post?(post) - post.present? && - (is_admin? || - ((is_moderator? || !post.deleted_at.present?) && - can_see_topic?(post.topic))) + return false if post.blank? + return true if is_admin? + return false unless can_see_topic?(post.topic) + return false unless post.user == @user || post.topic.visible_post_types(@user).include?(post.post_type) + return false if !is_moderator? && post.deleted_at.present? + + true end def can_view_edit_history?(post) diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 333e155b404..52222a6d3fc 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -191,11 +191,9 @@ class TopicView # Find the sort order for a post in the topic def sort_order_for_post_number(post_number) - Post.where(topic_id: @topic.id, post_number: post_number) - .with_deleted - .select(:sort_order) - .first - .try(:sort_order) + posts = Post.where(topic_id: @topic.id, post_number: post_number).with_deleted + posts = filter_post_types(posts) + posts.select(:sort_order).first.try(:sort_order) end # Filter to all posts near a particular post number @@ -332,11 +330,22 @@ class TopicView private + def filter_post_types(posts) + visible_types = @topic.visible_post_types(@user) + + if @user.present? + posts.where("user_id = ? OR post_type IN (?)", @user.id, visible_types) + else + posts.where(post_type: visible_types) + end + end + def filter_posts_by_ids(post_ids) # TODO: Sort might be off @posts = Post.where(id: post_ids, topic_id: @topic.id) .includes(:user, :reply_to_user) .order('sort_order') + @posts = filter_post_types(@posts) @posts = @posts.with_deleted if @guardian.can_see_deleted_posts? @posts end @@ -361,7 +370,7 @@ class TopicView end def unfiltered_posts - result = @topic.posts + result = filter_post_types(@topic.posts) result = result.with_deleted if @guardian.can_see_deleted_posts? result = @topic.posts.where("user_id IS NOT NULL") if @exclude_deleted_users result diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 87276664909..66430c8f82d 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -437,6 +437,32 @@ describe Guardian do expect(Guardian.new(user).can_see?(post)).to be_falsey expect(Guardian.new(admin).can_see?(post)).to be_truthy end + + it 'respects whispers' do + regular_post = Fabricate.build(:post) + whisper_post = Fabricate.build(:post, post_type: Post.types[:whisper]) + + anon_guardian = Guardian.new + expect(anon_guardian.can_see?(regular_post)).to eq(true) + expect(anon_guardian.can_see?(whisper_post)).to eq(false) + + regular_user = Fabricate.build(:user) + regular_guardian = Guardian.new(regular_user) + expect(regular_guardian.can_see?(regular_post)).to eq(true) + expect(regular_guardian.can_see?(whisper_post)).to eq(false) + + # can see your own whispers + regular_whisper = Fabricate.build(:post, post_type: Post.types[:whisper], user: regular_user) + expect(regular_guardian.can_see?(regular_whisper)).to eq(true) + + mod_guardian = Guardian.new(Fabricate.build(:moderator)) + expect(mod_guardian.can_see?(regular_post)).to eq(true) + expect(mod_guardian.can_see?(whisper_post)).to eq(true) + + admin_guardian = Guardian.new(Fabricate.build(:admin)) + expect(admin_guardian.can_see?(regular_post)).to eq(true) + expect(admin_guardian.can_see?(whisper_post)).to eq(true) + end end describe 'a PostRevision' do diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index dedc080b9f2..fe2658d975c 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -251,6 +251,23 @@ describe TopicView do end + context 'whispers' do + it "handles their visibility properly" do + p1 = Fabricate(:post, topic: topic, user: coding_horror) + p2 = Fabricate(:post, topic: topic, user: coding_horror, post_type: Post.types[:whisper]) + p3 = Fabricate(:post, topic: topic, user: coding_horror) + + ch_posts = TopicView.new(topic.id, coding_horror).posts + expect(ch_posts.map(&:id)).to eq([p1.id, p2.id, p3.id]) + + anon_posts = TopicView.new(topic.id).posts + expect(anon_posts.map(&:id)).to eq([p1.id, p3.id]) + + admin_posts = TopicView.new(topic.id, Fabricate(:moderator)).posts + expect(admin_posts.map(&:id)).to eq([p1.id, p2.id, p3.id]) + end + end + context '.posts' do # Create the posts in a different order than the sort_order diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 7471c5cd7bd..b65ec208c67 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -11,6 +11,40 @@ describe Topic do it { is_expected.to rate_limit } + context '#visible_post_types' do + let(:types) { Post.types } + + it "returns the appropriate types for anonymous users" do + topic = Fabricate.build(:topic) + post_types = topic.visible_post_types + + expect(post_types).to include(types[:regular]) + expect(post_types).to include(types[:moderator_action]) + expect(post_types).to include(types[:small_action]) + expect(post_types).to_not include(types[:whisper]) + end + + it "returns the appropriate types for regular users" do + topic = Fabricate.build(:topic) + post_types = topic.visible_post_types(Fabricate.build(:user)) + + expect(post_types).to include(types[:regular]) + expect(post_types).to include(types[:moderator_action]) + expect(post_types).to include(types[:small_action]) + expect(post_types).to_not include(types[:whisper]) + end + + it "returns the appropriate types for staff users" do + topic = Fabricate.build(:topic) + post_types = topic.visible_post_types(Fabricate.build(:moderator)) + + expect(post_types).to include(types[:regular]) + expect(post_types).to include(types[:moderator_action]) + expect(post_types).to include(types[:small_action]) + expect(post_types).to include(types[:whisper]) + end + end + context 'slug' do let(:title) { "hello world topic" } let(:slug) { "hello-world-topic" }