FEATURE: Whisper posts

This commit is contained in:
Robin Ward
2015-09-10 16:01:23 -04:00
parent 62cc029886
commit 5af0f5f80e
19 changed files with 186 additions and 29 deletions

View File

@ -3,6 +3,7 @@ import DiscourseURL from 'discourse/lib/url';
import Quote from 'discourse/lib/quote'; import Quote from 'discourse/lib/quote';
import Draft from 'discourse/models/draft'; import Draft from 'discourse/models/draft';
import Composer from 'discourse/models/composer'; import Composer from 'discourse/models/composer';
import computed from 'ember-addons/ember-computed-decorators';
function loadDraft(store, opts) { function loadDraft(store, opts) {
opts = opts || {}; opts = opts || {};
@ -64,6 +65,11 @@ export default Ember.Controller.extend({
this.set('similarTopics', []); this.set('similarTopics', []);
}.on('init'), }.on('init'),
@computed('model.action')
canWhisper(action) {
return this.siteSettings.enable_whispers && action === Composer.REPLY;
},
showWarning: function() { showWarning: function() {
if (!Discourse.User.currentProp('staff')) { return false; } if (!Discourse.User.currentProp('staff')) { return false; }
@ -132,7 +138,6 @@ export default Ember.Controller.extend({
}, },
hitEsc() { hitEsc() {
const messages = this.get('controllers.composer-messages.model'); const messages = this.get('controllers.composer-messages.model');
if (messages.length) { if (messages.length) {
messages.popObject(); messages.popObject();

View File

@ -24,6 +24,7 @@ const CLOSED = 'closed',
category: 'categoryId', category: 'categoryId',
topic_id: 'topic.id', topic_id: 'topic.id',
is_warning: 'isWarning', is_warning: 'isWarning',
whisper: 'whisper',
archetype: 'archetypeId', archetype: 'archetypeId',
target_usernames: 'targetUsernames', target_usernames: 'targetUsernames',
typing_duration_msecs: 'typingTime', typing_duration_msecs: 'typingTime',
@ -557,6 +558,9 @@ const Composer = RestModel.extend({
let addedToStream = false; let addedToStream = false;
const postTypes = this.site.get('post_types');
const postType = this.get('whisper') ? postTypes.whisper : postTypes.regular;
// Build the post object // Build the post object
const createdPost = this.store.createRecord('post', { const createdPost = this.store.createRecord('post', {
imageSizes: opts.imageSizes, imageSizes: opts.imageSizes,
@ -569,7 +573,7 @@ const Composer = RestModel.extend({
user_title: user.get('title'), user_title: user.get('title'),
avatar_template: user.get('avatar_template'), avatar_template: user.get('avatar_template'),
user_custom_fields: user.get('custom_fields'), user_custom_fields: user.get('custom_fields'),
post_type: this.site.get('post_types.regular'), post_type: postType,
actions_summary: [], actions_summary: [],
moderator: user.get('moderator'), moderator: user.get('moderator'),
admin: user.get('admin'), admin: user.get('admin'),

View File

@ -1,7 +1,7 @@
import RestModel from 'discourse/models/rest'; import RestModel from 'discourse/models/rest';
import { popupAjaxError } from 'discourse/lib/ajax-error'; import { popupAjaxError } from 'discourse/lib/ajax-error';
import ActionSummary from 'discourse/models/action-summary'; 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 Quote from 'discourse/lib/quote';
import computed from 'ember-addons/ember-computed-decorators'; 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'), topicOwner: propertyEqual('topic.details.created_by.id', 'user_id'),
hasHistory: Em.computed.gt('version', 1), hasHistory: Em.computed.gt('version', 1),
postElementId: fmt('post_number', 'post_%@'),
canViewRawEmail: function() { canViewRawEmail: function() {
return this.get("user_id") === Discourse.User.currentProp("id") || Discourse.User.currentProp('staff'); return this.get("user_id") === Discourse.User.currentProp("id") || Discourse.User.currentProp('staff');

View File

@ -60,6 +60,16 @@
{{/unless}} {{/unless}}
</div> </div>
{{/if}} {{/if}}
{{#if canWhisper}}
<div class='form-element clearfix'>
<label>
{{input type="checkbox" checked=model.whisper tabindex="3"}}
{{i18n "composer.add_whisper"}}
</label>
</div>
{{/if}}
{{plugin-outlet "composer-fields"}} {{plugin-outlet "composer-fields"}}
</div> </div>

View File

@ -8,7 +8,7 @@
{{view 'reply-history' content=replyHistory}} {{view 'reply-history' content=replyHistory}}
</div> </div>
<article {{bind-attr class=":boxed via_email" id="postElementId" data-post-id="id" data-user-id="user_id"}}> <article class="boxed {{if via_email 'via-email'}}" id={{postElementId}} data-post-id={{id}} data-user-id={{user_id}}>
<div class='row'> <div class='row'>
<div class='topic-avatar'> <div class='topic-avatar'>
@ -45,15 +45,20 @@
</div> </div>
{{/if}} {{/if}}
{{#if wiki}} {{#if wiki}}
<div class="post-info wiki" title="{{i18n 'post.wiki.about'}}" {{action "editPost" this}}>{{fa-icon "pencil-square-o"}}</div> <div class="post-info wiki" title={{i18n 'post.wiki.about'}} {{action "editPost" this}}>{{fa-icon "pencil-square-o"}}</div>
{{/if}} {{/if}}
{{#if via_email}} {{#if via_email}}
{{#if canViewRawEmail}} {{#if canViewRawEmail}}
<div class="post-info via-email raw-email" title="{{i18n 'post.via_email'}}" {{action "showRawEmail" this}}>{{fa-icon "envelope-o"}}</div> <div class="post-info via-email raw-email" title={{i18n 'post.via_email'}} {{action "showRawEmail" this}}>{{fa-icon "envelope-o"}}</div>
{{else}} {{else}}
<div class="post-info via-email" title="{{i18n 'post.via_email'}}">{{fa-icon "envelope-o"}}</div> <div class="post-info via-email" title={{i18n 'post.via_email'}}>{{fa-icon "envelope-o"}}</div>
{{/if}} {{/if}}
{{/if}} {{/if}}
{{#if view.whisper}}
<div class="post-info whisper" title={{i18n 'post.whisper'}}>{{fa-icon "user-secret"}}</div>
{{/if}}
{{#if showUserReplyTab}} {{#if showUserReplyTab}}
<a href {{action "toggleReplyHistory" this target="view"}} class='reply-to-tab'> <a href {{action "toggleReplyHistory" this target="view"}} class='reply-to-tab'>
{{#if loadingReplyHistory}} {{#if loadingReplyHistory}}

View File

@ -1,6 +1,8 @@
import ScreenTrack from 'discourse/lib/screen-track'; import ScreenTrack from 'discourse/lib/screen-track';
import { number } from 'discourse/lib/formatter'; import { number } from 'discourse/lib/formatter';
import DiscourseURL from 'discourse/lib/url'; 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; const DAY = 60 * 50 * 1000;
@ -12,10 +14,18 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, {
'post.deleted:deleted', 'post.deleted:deleted',
'post.topicOwner:topic-owner', 'post.topicOwner:topic-owner',
'groupNameClass', 'groupNameClass',
'post.wiki:wiki'], 'post.wiki:wiki',
'whisper'],
post: Ember.computed.alias('content'), 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() { templateName: function() {
return (this.get('post.post_type') === this.site.get('post_types.small_action')) ? 'post-small-action' : 'post'; return (this.get('post.post_type') === this.site.get('post_types.small_action')) ? 'post-small-action' : 'post';
}.property('post.post_type'), }.property('post.post_type'),

View File

@ -147,7 +147,7 @@ aside.quote {
} }
.post-info { .post-info {
&.wiki, &.via-email { &.wiki, &.via-email, &.whisper {
margin-right: 5px; margin-right: 5px;
i.fa { i.fa {
font-size: 1em; font-size: 1em;

View File

@ -582,6 +582,15 @@ a.mention {
} }
} }
.whisper {
.topic-body {
.cooked {
font-style: italic;
color: dark-light-diff($primary, $secondary, 55%, -40%);
}
}
}
#share-link { #share-link {
width: 365px; width: 365px;
margin-left: -4px; margin-left: -4px;

View File

@ -465,6 +465,10 @@ class PostsController < ApplicationController
result[:is_warning] = false result[:is_warning] = false
end end
if SiteSetting.enable_whispers? && params[:whisper] == "true"
result[:post_type] = Post.types[:whisper]
end
PostRevisor.tracked_topic_fields.each_key do |f| PostRevisor.tracked_topic_fields.each_key do |f|
params.permit(f => []) params.permit(f => [])
result[f] = params[f] if params.has_key?(f) result[f] = params[f] if params.has_key?(f)

View File

@ -74,7 +74,7 @@ class Post < ActiveRecord::Base
end end
def self.types def self.types
@types ||= Enum.new(:regular, :moderator_action, :small_action) @types ||= Enum.new(:regular, :moderator_action, :small_action, :whisper)
end end
def self.cook_methods def self.cook_methods
@ -96,15 +96,24 @@ class Post < ActiveRecord::Base
end end
def publish_change_to_clients!(type) def publish_change_to_clients!(type)
# special failsafe for posts missing topics
# consistency checks should fix, but message channel = "/topic/#{topic_id}"
# is safe to skip msg = { id: id,
MessageBus.publish("/topic/#{topic_id}", {
id: id,
post_number: post_number, post_number: post_number,
updated_at: Time.now, updated_at: Time.now,
type: type type: type }
}, group_ids: topic.secure_group_ids) if topic
# special failsafe for posts missing topics consistency checks should fix, but message
# is safe to skip
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 end
def trash!(trashed_by=nil) def trash!(trashed_by=nil)

View File

@ -218,6 +218,13 @@ class Topic < ActiveRecord::Base
end end
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) def self.top_viewed(max = 10)
Topic.listable_topics.visible.secured.order('views desc').limit(max) Topic.listable_topics.visible.secured.order('views desc').limit(max)
end end

View File

@ -809,6 +809,7 @@ en:
emoji: "Emoji :smile:" emoji: "Emoji :smile:"
add_warning: "This is an official warning." 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?" posting_not_on_topic: "Which topic do you want to reply to?"
saving_draft_tip: "saving..." saving_draft_tip: "saving..."
saved_draft_tip: "saved" saved_draft_tip: "saved"
@ -1349,6 +1350,7 @@ en:
yes_value: "Yes, abandon" yes_value: "Yes, abandon"
via_email: "this post arrived via email" via_email: "this post arrived via email"
whisper: "this post is a private whisper for moderators"
wiki: wiki:
about: "this post is a wiki; basic users can edit it" about: "this post is a wiki; basic users can edit it"

View File

@ -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." 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_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." 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" email_domains_blacklist: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net"

View File

@ -182,6 +182,9 @@ basic:
enable_badges: enable_badges:
client: true client: true
default: true default: true
enable_whispers:
client: true
default: false
login: login:
invite_only: invite_only:

View File

@ -144,10 +144,13 @@ module PostGuardian
end end
def can_see_post?(post) def can_see_post?(post)
post.present? && return false if post.blank?
(is_admin? || return true if is_admin?
((is_moderator? || !post.deleted_at.present?) && return false unless can_see_topic?(post.topic)
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 end
def can_view_edit_history?(post) def can_view_edit_history?(post)

View File

@ -191,11 +191,9 @@ class TopicView
# Find the sort order for a post in the topic # Find the sort order for a post in the topic
def sort_order_for_post_number(post_number) def sort_order_for_post_number(post_number)
Post.where(topic_id: @topic.id, post_number: post_number) posts = Post.where(topic_id: @topic.id, post_number: post_number).with_deleted
.with_deleted posts = filter_post_types(posts)
.select(:sort_order) posts.select(:sort_order).first.try(:sort_order)
.first
.try(:sort_order)
end end
# Filter to all posts near a particular post number # Filter to all posts near a particular post number
@ -332,11 +330,22 @@ class TopicView
private 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) def filter_posts_by_ids(post_ids)
# TODO: Sort might be off # TODO: Sort might be off
@posts = Post.where(id: post_ids, topic_id: @topic.id) @posts = Post.where(id: post_ids, topic_id: @topic.id)
.includes(:user, :reply_to_user) .includes(:user, :reply_to_user)
.order('sort_order') .order('sort_order')
@posts = filter_post_types(@posts)
@posts = @posts.with_deleted if @guardian.can_see_deleted_posts? @posts = @posts.with_deleted if @guardian.can_see_deleted_posts?
@posts @posts
end end
@ -361,7 +370,7 @@ class TopicView
end end
def unfiltered_posts def unfiltered_posts
result = @topic.posts result = filter_post_types(@topic.posts)
result = result.with_deleted if @guardian.can_see_deleted_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 = @topic.posts.where("user_id IS NOT NULL") if @exclude_deleted_users
result result

View File

@ -437,6 +437,32 @@ describe Guardian do
expect(Guardian.new(user).can_see?(post)).to be_falsey expect(Guardian.new(user).can_see?(post)).to be_falsey
expect(Guardian.new(admin).can_see?(post)).to be_truthy expect(Guardian.new(admin).can_see?(post)).to be_truthy
end 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 end
describe 'a PostRevision' do describe 'a PostRevision' do

View File

@ -251,6 +251,23 @@ describe TopicView do
end 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 context '.posts' do
# Create the posts in a different order than the sort_order # Create the posts in a different order than the sort_order

View File

@ -11,6 +11,40 @@ describe Topic do
it { is_expected.to rate_limit } 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 context 'slug' do
let(:title) { "hello world topic" } let(:title) { "hello world topic" }
let(:slug) { "hello-world-topic" } let(:slug) { "hello-world-topic" }