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}}
-
{{fa-icon "pencil-square-o"}}
+
{{fa-icon "pencil-square-o"}}
{{/if}}
{{#if via_email}}
{{#if canViewRawEmail}}
-
{{fa-icon "envelope-o"}}
+
{{fa-icon "envelope-o"}}
{{else}}
-
{{fa-icon "envelope-o"}}
+
{{fa-icon "envelope-o"}}
{{/if}}
{{/if}}
+
+ {{#if view.whisper}}
+
{{fa-icon "user-secret"}}
+ {{/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" }