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 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();

View File

@ -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'),

View File

@ -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');

View File

@ -60,6 +60,16 @@
{{/unless}}
</div>
{{/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"}}
</div>

View File

@ -8,7 +8,7 @@
{{view 'reply-history' content=replyHistory}}
</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='topic-avatar'>
@ -45,15 +45,20 @@
</div>
{{/if}}
{{#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 via_email}}
{{#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}}
<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 view.whisper}}
<div class="post-info whisper" title={{i18n 'post.whisper'}}>{{fa-icon "user-secret"}}</div>
{{/if}}
{{#if showUserReplyTab}}
<a href {{action "toggleReplyHistory" this target="view"}} class='reply-to-tab'>
{{#if loadingReplyHistory}}

View File

@ -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'),

View File

@ -147,7 +147,7 @@ aside.quote {
}
.post-info {
&.wiki, &.via-email {
&.wiki, &.via-email, &.whisper {
margin-right: 5px;
i.fa {
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 {
width: 365px;
margin-left: -4px;

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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"

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."
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"

View File

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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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" }