FEATURE: whispers available for groups (#17170)

Before, whispers were only available for staff members.

Config has been changed to allow to configure privileged groups with access to whispers. Post migration was added to move from the old setting into the new one.

I considered having a boolean column `whisperer` on user model similar to `admin/moderator` for performance reason. Finally, I decided to keep looking for groups as queries are only done for current user and didn't notice any N+1 queries.
This commit is contained in:
Krzysztof Kotlarek 2022-06-30 10:18:12 +10:00 committed by GitHub
parent f44eb13236
commit 09932738e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 275 additions and 56 deletions

View File

@ -233,6 +233,7 @@ export default Controller.extend({
}, },
isStaffUser: reads("currentUser.staff"), isStaffUser: reads("currentUser.staff"),
whisperer: reads("currentUser.whisperer"),
canUnlistTopic: and("model.creatingTopic", "isStaffUser"), canUnlistTopic: and("model.creatingTopic", "isStaffUser"),
@ -289,12 +290,12 @@ export default Controller.extend({
return SAVE_LABELS[modelAction]; return SAVE_LABELS[modelAction];
}, },
@discourseComputed("isStaffUser", "model.action") @discourseComputed("whisperer", "model.action")
canWhisper(isStaffUser, modelAction) { canWhisper(whisperer, modelAction) {
return ( return (
this.siteSettings.enable_whispers && this.siteSettings.enable_whispers &&
isStaffUser && Composer.REPLY === modelAction &&
Composer.REPLY === modelAction whisperer
); );
}, },

View File

@ -19,7 +19,11 @@ import userFixtures from "discourse/tests/fixtures/user-fixtures";
import { cloneJSON } from "discourse-common/lib/object"; import { cloneJSON } from "discourse-common/lib/object";
acceptance("Composer Actions", function (needs) { acceptance("Composer Actions", function (needs) {
needs.user(); needs.user({
id: 5,
username: "kris",
whisperer: true,
});
needs.settings({ needs.settings({
prioritize_username_in_ux: true, prioritize_username_in_ux: true,
display_name_on_post: false, display_name_on_post: false,
@ -78,7 +82,8 @@ acceptance("Composer Actions", function (needs) {
); );
}); });
test("replying to post - toggle_whisper", async function (assert) { test("replying to post - toggle_whisper for whisperers", async function (assert) {
updateCurrentUser({ admin: false, moderator: false });
const composerActions = selectKit(".composer-actions"); const composerActions = selectKit(".composer-actions");
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
@ -346,7 +351,13 @@ acceptance("Composer Actions", function (needs) {
test("replying to post as TL3 user", async function (assert) { test("replying to post as TL3 user", async function (assert) {
const composerActions = selectKit(".composer-actions"); const composerActions = selectKit(".composer-actions");
updateCurrentUser({ moderator: false, admin: false, trust_level: 3 }); updateCurrentUser({
moderator: false,
admin: false,
trust_level: 3,
whisperer: false,
groups: [{ id: 13, name: "tl3_group" }],
});
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click("article#post_3 button.reply"); await click("article#post_3 button.reply");
await composerActions.expand(); await composerActions.expand();
@ -364,7 +375,13 @@ acceptance("Composer Actions", function (needs) {
test("replying to post as TL4 user", async function (assert) { test("replying to post as TL4 user", async function (assert) {
const composerActions = selectKit(".composer-actions"); const composerActions = selectKit(".composer-actions");
updateCurrentUser({ moderator: false, admin: false, trust_level: 4 }); updateCurrentUser({
moderator: false,
admin: false,
trust_level: 4,
whisperer: false,
groups: [{ id: 13, name: "tl4_group" }],
});
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click("article#post_3 button.reply"); await click("article#post_3 button.reply");
await composerActions.expand(); await composerActions.expand();

View File

@ -26,8 +26,14 @@ import { Promise } from "rsvp";
import sinon from "sinon"; import sinon from "sinon";
acceptance("Composer", function (needs) { acceptance("Composer", function (needs) {
needs.user(); needs.user({
needs.settings({ enable_whispers: true }); id: 5,
username: "kris",
whisperer: true,
});
needs.settings({
enable_whispers: true,
});
needs.site({ can_tag_topics: true }); needs.site({ can_tag_topics: true });
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
server.post("/uploads/lookup-urls", () => { server.post("/uploads/lookup-urls", () => {
@ -455,7 +461,7 @@ acceptance("Composer", function (needs) {
); );
}); });
test("Composer can toggle whispers", async function (assert) { test("Composer can toggle whispers when whisperer user", async function (assert) {
const menu = selectKit(".toolbar-popup-menu-options"); const menu = selectKit(".toolbar-popup-menu-options");
await visit("/t/this-is-a-test-topic/9"); await visit("/t/this-is-a-test-topic/9");

View File

@ -1257,7 +1257,7 @@ class TopicsController < ApplicationController
topic_query.options[:limit] = false topic_query.options[:limit] = false
topics = topic_query.filter_private_messages_unread(current_user, filter) topics = topic_query.filter_private_messages_unread(current_user, filter)
else else
topics = TopicQuery.unread_filter(topic_query.joined_topic_user, staff: guardian.is_staff?).listable_topics topics = TopicQuery.unread_filter(topic_query.joined_topic_user, whisperer: guardian.is_whisperer?).listable_topics
topics = TopicQuery.tracked_filter(topics, current_user.id) if params[:tracked].to_s == "true" topics = TopicQuery.tracked_filter(topics, current_user.id) if params[:tracked].to_s == "true"
if params[:category_id] if params[:category_id]

View File

@ -18,6 +18,17 @@ module Roleable
!staff? !staff?
end end
def whisperer?
@whisperer ||= begin
return false if !SiteSetting.enable_whispers?
return true if staff?
whispers_allowed_group_ids = SiteSetting.whispers_allowed_group_ids
return false if whispers_allowed_group_ids.blank?
return true if whispers_allowed_group_ids.include?(primary_group_id)
group_users&.exists?(group_id: whispers_allowed_group_ids)
end
end
def grant_moderation! def grant_moderation!
return if moderator return if moderator
set_permission('moderator', true) set_permission('moderator', true)
@ -61,6 +72,11 @@ module Roleable
end end
end end
def reload(options = nil)
@whisperer = nil
super(options)
end
private private
def auto_approve_user def auto_approve_user

View File

@ -12,7 +12,7 @@ module TopicTrackingStatePublishable
notification_level: nil) notification_level: nil)
highest_post_number = DB.query_single( highest_post_number = DB.query_single(
"SELECT #{user.staff? ? "highest_staff_post_number" : "highest_post_number"} FROM topics WHERE id = ?", "SELECT #{user.whisperer? ? "highest_staff_post_number" : "highest_post_number"} FROM topics WHERE id = ?",
topic_id topic_id
).first ).first

View File

@ -24,7 +24,9 @@ class GroupUser < ActiveRecord::Base
end end
def self.update_first_unread_pm(last_seen, limit: 10_000) def self.update_first_unread_pm(last_seen, limit: 10_000)
DB.exec(<<~SQL, archetype: Archetype.private_message, last_seen: last_seen, limit: limit, now: 10.minutes.ago) whisperers_group_ids = SiteSetting.whispers_allowed_group_ids
DB.exec(<<~SQL, archetype: Archetype.private_message, last_seen: last_seen, limit: limit, now: 10.minutes.ago, whisperers_group_ids: whisperers_group_ids)
UPDATE group_users gu UPDATE group_users gu
SET first_unread_pm_at = Y.min_date SET first_unread_pm_at = Y.min_date
FROM ( FROM (
@ -51,7 +53,7 @@ class GroupUser < ActiveRecord::Base
WHERE t.deleted_at IS NULL WHERE t.deleted_at IS NULL
AND t.archetype = :archetype AND t.archetype = :archetype
AND tu.last_read_post_number < CASE AND tu.last_read_post_number < CASE
WHEN u.admin OR u.moderator WHEN u.admin OR u.moderator #{whisperers_group_ids.present? ? 'OR gu2.group_id IN (:whisperers_group_ids)' : ''}
THEN t.highest_staff_post_number THEN t.highest_staff_post_number
ELSE t.highest_post_number ELSE t.highest_post_number
END END

View File

@ -60,7 +60,7 @@ class PostTiming < ActiveRecord::Base
def self.destroy_last_for(user, topic_id: nil, topic: nil) def self.destroy_last_for(user, topic_id: nil, topic: nil)
topic ||= Topic.find(topic_id) topic ||= Topic.find(topic_id)
post_number = user.staff? ? topic.highest_staff_post_number : topic.highest_post_number post_number = user.whisperer? ? topic.highest_staff_post_number : topic.highest_post_number
last_read = post_number - 1 last_read = post_number - 1

View File

@ -61,7 +61,7 @@ class PrivateMessageTopicTrackingState
SQL SQL
<<~SQL <<~SQL
#{TopicTrackingState.unread_filter_sql(staff: user.staff?)} #{TopicTrackingState.unread_filter_sql(whisperer: user.whisperer?)}
#{first_unread_pm_at ? "AND topics.updated_at > '#{first_unread_pm_at}'" : ""} #{first_unread_pm_at ? "AND topics.updated_at > '#{first_unread_pm_at}'" : ""}
SQL SQL
end end
@ -79,7 +79,7 @@ class PrivateMessageTopicTrackingState
u.id AS user_id, u.id AS user_id,
last_read_post_number, last_read_post_number,
tu.notification_level, tu.notification_level,
#{TopicTrackingState.highest_post_number_column_select(user.staff?)}, #{TopicTrackingState.highest_post_number_column_select(user.whisperer?)},
ARRAY(SELECT group_id FROM topic_allowed_groups WHERE topic_allowed_groups.topic_id = topics.id) AS group_ids ARRAY(SELECT group_id FROM topic_allowed_groups WHERE topic_allowed_groups.topic_id = topics.id) AS group_ids
FROM topics FROM topics
JOIN users u on u.id = #{user.id.to_i} JOIN users u on u.id = #{user.id.to_i}

View File

@ -191,6 +191,14 @@ class SiteSetting < ActiveRecord::Base
SiteSetting::Upload SiteSetting::Upload
end end
def self.whispers_allowed_group_ids
if SiteSetting.enable_whispers && SiteSetting.whispers_allowed_groups.present?
SiteSetting.whispers_allowed_groups.split("|").map(&:to_i)
else
[]
end
end
def self.require_invite_code def self.require_invite_code
invite_code.present? invite_code.present?
end end

View File

@ -404,7 +404,7 @@ class Topic < ActiveRecord::Base
types = Post.types types = Post.types
result = [types[:regular]] result = [types[:regular]]
result += [types[:moderator_action], types[:small_action]] if include_moderator_actions result += [types[:moderator_action], types[:small_action]] if include_moderator_actions
result << types[:whisper] if viewed_by&.staff? result << types[:whisper] if viewed_by&.whisperer?
result result
end end

View File

@ -65,7 +65,7 @@ class TopicTrackingState
publish_read(topic.id, 1, topic.user) publish_read(topic.id, 1, topic.user)
end end
def self.publish_latest(topic, staff_only = false) def self.publish_latest(topic, whisper = false)
return unless topic.regular? return unless topic.regular?
tag_ids, tags = nil tag_ids, tags = nil
@ -89,8 +89,8 @@ class TopicTrackingState
end end
group_ids = group_ids =
if staff_only if whisper
[Group::AUTO_GROUPS[:staff]] [Group::AUTO_GROUPS[:staff], *SiteSetting.whispers_allowed_group_ids]
else else
topic.category && topic.category.secure_group_ids topic.category && topic.category.secure_group_ids
end end
@ -151,7 +151,7 @@ class TopicTrackingState
group_ids = group_ids =
if post.post_type == Post.types[:whisper] if post.post_type == Post.types[:whisper]
[Group::AUTO_GROUPS[:staff]] [Group::AUTO_GROUPS[:staff], *SiteSetting.whispers_allowed_group_ids]
else else
post.topic.category && post.topic.category.secure_group_ids post.topic.category && post.topic.category.secure_group_ids
end end
@ -253,8 +253,8 @@ class TopicTrackingState
" AND dismissed_topic_users.id IS NULL" " AND dismissed_topic_users.id IS NULL"
end end
def self.unread_filter_sql(staff: false) def self.unread_filter_sql(whisperer: false)
TopicQuery.unread_filter(Topic, staff: staff).where_clause.ast.to_sql TopicQuery.unread_filter(Topic, whisperer: whisperer).where_clause.ast.to_sql
end end
def self.treat_as_new_topic_clause def self.treat_as_new_topic_clause
@ -319,6 +319,7 @@ class TopicTrackingState
skip_order: true, skip_order: true,
staff: user.staff?, staff: user.staff?,
admin: user.admin?, admin: user.admin?,
whisperer: user.whisperer?,
user: user, user: user,
muted_tag_ids: tag_ids muted_tag_ids: tag_ids
) )
@ -332,6 +333,7 @@ class TopicTrackingState
staff: user.staff?, staff: user.staff?,
filter_old_unread: true, filter_old_unread: true,
admin: user.admin?, admin: user.admin?,
whisperer: user.whisperer?,
user: user, user: user,
muted_tag_ids: tag_ids muted_tag_ids: tag_ids
) )
@ -369,6 +371,7 @@ class TopicTrackingState
skip_order: false, skip_order: false,
staff: false, staff: false,
admin: false, admin: false,
whisperer: false,
select: nil, select: nil,
custom_state_filter: nil, custom_state_filter: nil,
additional_join_sql: nil additional_join_sql: nil
@ -377,7 +380,7 @@ class TopicTrackingState
if skip_unread if skip_unread
"1=0" "1=0"
else else
unread_filter_sql(staff: staff) unread_filter_sql(whisperer: whisperer)
end end
filter_old_unread_sql = filter_old_unread_sql =
@ -399,7 +402,7 @@ class TopicTrackingState
u.id as user_id, u.id as user_id,
topics.created_at, topics.created_at,
topics.updated_at, topics.updated_at,
#{highest_post_number_column_select(staff)}, #{highest_post_number_column_select(whisperer)},
last_read_post_number, last_read_post_number,
c.id as category_id, c.id as category_id,
tu.notification_level, tu.notification_level,
@ -497,8 +500,8 @@ class TopicTrackingState
sql sql
end end
def self.highest_post_number_column_select(staff) def self.highest_post_number_column_select(whisperer)
"#{staff ? "topics.highest_staff_post_number AS highest_post_number" : "topics.highest_post_number"}" "#{whisperer ? "topics.highest_staff_post_number AS highest_post_number" : "topics.highest_post_number"}"
end end
def self.publish_read_indicator_on_write(topic_id, last_read_post_number, user_id) def self.publish_read_indicator_on_write(topic_id, last_read_post_number, user_id)

View File

@ -19,7 +19,9 @@ class UserStat < ActiveRecord::Base
UPDATE_UNREAD_USERS_LIMIT = 10_000 UPDATE_UNREAD_USERS_LIMIT = 10_000
def self.update_first_unread_pm(last_seen, limit: UPDATE_UNREAD_USERS_LIMIT) def self.update_first_unread_pm(last_seen, limit: UPDATE_UNREAD_USERS_LIMIT)
DB.exec(<<~SQL, archetype: Archetype.private_message, now: UPDATE_UNREAD_MINUTES_AGO.minutes.ago, last_seen: last_seen, limit: limit) whisperers_group_ids = SiteSetting.whispers_allowed_group_ids
DB.exec(<<~SQL, archetype: Archetype.private_message, now: UPDATE_UNREAD_MINUTES_AGO.minutes.ago, last_seen: last_seen, limit: limit, whisperers_group_ids: whisperers_group_ids)
UPDATE user_stats us UPDATE user_stats us
SET first_unread_pm_at = COALESCE(Z.min_date, :now) SET first_unread_pm_at = COALESCE(Z.min_date, :now)
FROM ( FROM (
@ -35,10 +37,11 @@ class UserStat < ActiveRecord::Base
INNER JOIN topics t ON t.id = tau.topic_id INNER JOIN topics t ON t.id = tau.topic_id
INNER JOIN users u ON u.id = tau.user_id INNER JOIN users u ON u.id = tau.user_id
LEFT JOIN topic_users tu ON t.id = tu.topic_id AND tu.user_id = tau.user_id LEFT JOIN topic_users tu ON t.id = tu.topic_id AND tu.user_id = tau.user_id
#{whisperers_group_ids.present? ? 'LEFT JOIN group_users gu ON gu.group_id IN (:whisperers_group_ids) AND gu.user_id = u.id' : ''}
WHERE t.deleted_at IS NULL WHERE t.deleted_at IS NULL
AND t.archetype = :archetype AND t.archetype = :archetype
AND tu.last_read_post_number < CASE AND tu.last_read_post_number < CASE
WHEN u.admin OR u.moderator WHEN u.admin OR u.moderator #{whisperers_group_ids.present? ? 'OR gu.id IS NOT NULL' : ''}
THEN t.highest_staff_post_number THEN t.highest_staff_post_number
ELSE t.highest_post_number ELSE t.highest_post_number
END END

View File

@ -12,6 +12,7 @@ class CurrentUserSerializer < BasicUserSerializer
:notification_channel_position, :notification_channel_position,
:moderator?, :moderator?,
:staff?, :staff?,
:whisperer?,
:title, :title,
:any_posts, :any_posts,
:enable_quoting, :enable_quoting,

View File

@ -54,7 +54,7 @@ class ListableTopicSerializer < BasicTopicSerializer
end end
def highest_post_number def highest_post_number
(scope.is_staff? && object.highest_staff_post_number) || object.highest_post_number (scope.is_whisperer? && object.highest_staff_post_number) || object.highest_post_number
end end
def liked def liked

View File

@ -48,7 +48,7 @@ class UserPostTopicBookmarkBaseSerializer < UserBookmarkBaseSerializer
end end
def highest_post_number def highest_post_number
scope.is_staff? ? topic.highest_staff_post_number : topic.highest_post_number scope.is_whisperer? ? topic.highest_staff_post_number : topic.highest_post_number
end end
def last_read_post_number def last_read_post_number

View File

@ -1683,6 +1683,7 @@ en:
enable_badges: "Enable the badge system" enable_badges: "Enable the badge system"
max_favorite_badges: "Maximum number of badges that user can select" max_favorite_badges: "Maximum number of badges that user can select"
enable_whispers: "Allow staff private communication within topics." enable_whispers: "Allow staff private communication within topics."
whispers_allowed_groups: "Allow private communication within topics for members of specified groups."
allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines. In exceptional cases you can permanently <a href='%{base_path}/admin/customize/robots'>override robots.txt</a>." allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines. In exceptional cases you can permanently <a href='%{base_path}/admin/customize/robots'>override robots.txt</a>."
blocked_email_domains: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net" blocked_email_domains: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net"

View File

@ -323,6 +323,13 @@ basic:
enable_whispers: enable_whispers:
client: true client: true
default: false default: false
whispers_allowed_groups:
client: true
type: group_list
list_type: compact
default: ""
allow_any: false
refresh: true
enable_bookmarks_with_reminders: enable_bookmarks_with_reminders:
client: true client: true
default: true default: true

View File

@ -56,6 +56,9 @@ class Guardian
def topic_create_allowed_category_ids def topic_create_allowed_category_ids
[] []
end end
def groups
[]
end
def has_trust_level?(level) def has_trust_level?(level)
false false
end end
@ -65,6 +68,9 @@ class Guardian
def email def email
nil nil
end end
def whisperer?
false
end
end end
attr_reader :request attr_reader :request
@ -99,6 +105,10 @@ class Guardian
@user.moderator? @user.moderator?
end end
def is_whisperer?
@user.whisperer?
end
def is_category_group_moderator?(category) def is_category_group_moderator?(category)
return false unless category return false unless category
return false unless authenticated? return false unless authenticated?

View File

@ -36,11 +36,11 @@ module TopicGuardian
end end
def can_create_whisper? def can_create_whisper?
is_staff? && SiteSetting.enable_whispers? @user.whisperer?
end end
def can_see_whispers?(_topic) def can_see_whispers?(_topic = nil)
is_staff? @user.whisperer?
end end
def can_publish_topic?(topic, category) def can_publish_topic?(topic, category)

View File

@ -336,8 +336,8 @@ class TopicQuery
.where("COALESCE(tu.notification_level, :tracking) >= :tracking", tracking: TopicUser.notification_levels[:tracking]) .where("COALESCE(tu.notification_level, :tracking) >= :tracking", tracking: TopicUser.notification_levels[:tracking])
end end
def self.unread_filter(list, staff: false) def self.unread_filter(list, whisperer: false)
col_name = staff ? "highest_staff_post_number" : "highest_post_number" col_name = whisperer ? "highest_staff_post_number" : "highest_post_number"
list list
.where("tu.last_read_post_number < topics.#{col_name}") .where("tu.last_read_post_number < topics.#{col_name}")
@ -474,7 +474,7 @@ class TopicQuery
def unseen_results(options = {}) def unseen_results(options = {})
result = default_results(options) result = default_results(options)
result = unseen_filter(result, @user.first_seen_at, @user.staff?) if @user result = unseen_filter(result, @user.first_seen_at, @user.whisperer?) if @user
result = remove_muted(result, @user, options) result = remove_muted(result, @user, options)
result = apply_shared_drafts(result, get_category_id(options[:category]), options) result = apply_shared_drafts(result, get_category_id(options[:category]), options)
@ -489,7 +489,7 @@ class TopicQuery
def unread_results(options = {}) def unread_results(options = {})
result = TopicQuery.unread_filter( result = TopicQuery.unread_filter(
default_results(options.reverse_merge(unordered: true)), default_results(options.reverse_merge(unordered: true)),
staff: @user&.staff?) whisperer: @user&.whisperer?)
.order('CASE WHEN topics.user_id = tu.user_id THEN 1 ELSE 2 END') .order('CASE WHEN topics.user_id = tu.user_id THEN 1 ELSE 2 END')
if @user if @user
@ -948,7 +948,7 @@ class TopicQuery
def unread_messages(params) def unread_messages(params)
query = TopicQuery.unread_filter( query = TopicQuery.unread_filter(
messages_for_groups_or_user(params[:my_group_ids]), messages_for_groups_or_user(params[:my_group_ids]),
staff: @user.staff? whisperer: @user.whisperer?
) )
first_unread_pm_at = first_unread_pm_at =
@ -1084,10 +1084,10 @@ class TopicQuery
private private
def unseen_filter(list, user_first_seen_at, staff) def unseen_filter(list, user_first_seen_at, whisperer)
list = list.where("topics.bumped_at >= ?", user_first_seen_at) list = list.where("topics.bumped_at >= ?", user_first_seen_at)
col_name = staff ? "highest_staff_post_number" : "highest_post_number" col_name = whisperer ? "highest_staff_post_number" : "highest_post_number"
list.where("tu.last_read_post_number IS NULL OR tu.last_read_post_number < topics.#{col_name}") list.where("tu.last_read_post_number IS NULL OR tu.last_read_post_number < topics.#{col_name}")
end end
end end

View File

@ -165,7 +165,7 @@ class TopicQuery
def filter_private_messages_unread(user, type) def filter_private_messages_unread(user, type)
list = TopicQuery.unread_filter( list = TopicQuery.unread_filter(
private_messages_for(user, type), private_messages_for(user, type),
staff: user.staff? whisperer: user.whisperer?
) )
first_unread_pm_at = first_unread_pm_at =

View File

@ -69,7 +69,7 @@ class TopicsBulkAction
end end
def dismiss_posts def dismiss_posts
highest_number_source_column = @user.staff? ? 'highest_staff_post_number' : 'highest_post_number' highest_number_source_column = @user.whisperer? ? 'highest_staff_post_number' : 'highest_post_number'
sql = <<~SQL sql = <<~SQL
UPDATE topic_users tu UPDATE topic_users tu
SET last_read_post_number = t.#{highest_number_source_column} SET last_read_post_number = t.#{highest_number_source_column}

View File

@ -14,7 +14,7 @@ class Unread
return 0 if @topic_user.last_read_post_number.blank? return 0 if @topic_user.last_read_post_number.blank?
return 0 if do_not_notify?(@topic_user.notification_level) return 0 if do_not_notify?(@topic_user.notification_level)
highest_post_number = @guardian.is_staff? ? @topic.highest_staff_post_number : @topic.highest_post_number highest_post_number = @guardian.is_whisperer? ? @topic.highest_staff_post_number : @topic.highest_post_number
return 0 if @topic_user.last_read_post_number > highest_post_number return 0 if @topic_user.last_read_post_number > highest_post_number

View File

@ -42,7 +42,7 @@ export default Component.extend({
@discourseComputed("model.topic.id", "isReply", "isWhisper") @discourseComputed("model.topic.id", "isReply", "isWhisper")
whisperChannelName(topicId, isReply, isWhisper) { whisperChannelName(topicId, isReply, isWhisper) {
if (topicId && this.currentUser.staff && (isReply || isWhisper)) { if (topicId && this.currentUser.whisperer && (isReply || isWhisper)) {
return `/discourse-presence/whisper/${topicId}`; return `/discourse-presence/whisper/${topicId}`;
} }
}, },

View File

@ -15,7 +15,7 @@ import User from "discourse/models/user";
import selectKit from "discourse/tests/helpers/select-kit-helper"; import selectKit from "discourse/tests/helpers/select-kit-helper";
acceptance("Discourse Presence Plugin", function (needs) { acceptance("Discourse Presence Plugin", function (needs) {
needs.user(); needs.user({ whisperer: true });
needs.settings({ enable_whispers: true }); needs.settings({ enable_whispers: true });
test("Doesn't break topic creation", async function (assert) { test("Doesn't break topic creation", async function (assert) {

View File

@ -119,7 +119,10 @@ RSpec.describe BookmarkQuery do
context "for a whispered post" do context "for a whispered post" do
before do before do
post_bookmark.bookmarkable.update(post_type: Post.types[:whisper]) post_bookmark.bookmarkable.update(post_type: Post.types[:whisper])
SiteSetting.enable_whispers = true
end end
fab!(:whisperers_group) { Fabricate(:group) }
context "when the user is moderator" do context "when the user is moderator" do
it "does return the whispered post" do it "does return the whispered post" do
user.update!(moderator: true) user.update!(moderator: true)
@ -132,6 +135,13 @@ RSpec.describe BookmarkQuery do
expect(bookmark_query.list_all.count).to eq(3) expect(bookmark_query.list_all.count).to eq(3)
end end
end end
context "when the user is a member of whisperers group" do
it "returns the whispered post" do
SiteSetting.whispers_allowed_groups = "#{whisperers_group.id}"
user.update!(groups: [whisperers_group])
expect(bookmark_query.list_all.count).to eq(3)
end
end
context "when the user is not staff" do context "when the user is not staff" do
it "does not return the whispered post" do it "does not return the whispered post" do
expect(bookmark_query.list_all.count).to eq(2) expect(bookmark_query.list_all.count).to eq(2)

View File

@ -893,6 +893,8 @@ describe Guardian do
end end
it 'respects whispers' do it 'respects whispers' do
SiteSetting.enable_whispers = true
SiteSetting.whispers_allowed_groups = "#{group.id}"
regular_post = post regular_post = post
whisper_post = Fabricate.build(:post, post_type: Post.types[:whisper]) whisper_post = Fabricate.build(:post, post_type: Post.types[:whisper])
@ -916,6 +918,10 @@ describe Guardian do
admin_guardian = Guardian.new(Fabricate.build(:admin)) admin_guardian = Guardian.new(Fabricate.build(:admin))
expect(admin_guardian.can_see?(regular_post)).to eq(true) expect(admin_guardian.can_see?(regular_post)).to eq(true)
expect(admin_guardian.can_see?(whisper_post)).to eq(true) expect(admin_guardian.can_see?(whisper_post)).to eq(true)
whisperer_guardian = Guardian.new(Fabricate(:user, groups: [group]))
expect(whisperer_guardian.can_see?(regular_post)).to eq(true)
expect(whisperer_guardian.can_see?(whisper_post)).to eq(true)
end end
end end

View File

@ -656,7 +656,7 @@ describe PostDestroyer do
it 'should not set Topic#last_post_user_id to a whisperer' do it 'should not set Topic#last_post_user_id to a whisperer' do
post_1 = create_post(topic: post.topic, user: moderator) post_1 = create_post(topic: post.topic, user: moderator)
whisper_1 = create_post(topic: post.topic, user: Fabricate(:user), post_type: Post.types[:whisper]) create_post(topic: post.topic, user: Fabricate(:user), post_type: Post.types[:whisper])
whisper_2 = create_post(topic: post.topic, user: Fabricate(:user), post_type: Post.types[:whisper]) whisper_2 = create_post(topic: post.topic, user: Fabricate(:user), post_type: Post.types[:whisper])
PostDestroyer.new(admin, whisper_2).destroy PostDestroyer.new(admin, whisper_2).destroy

View File

@ -910,7 +910,12 @@ describe Search do
]) ])
end end
it 'allows staff to search for whispers' do it 'allows staff and members of whisperers group to search for whispers' do
whisperers_group = Fabricate(:group)
user = Fabricate(:user)
SiteSetting.enable_whispers = true
SiteSetting.whispers_allowed_groups = "#{whisperers_group.id}"
post.update!(post_type: Post.types[:whisper], raw: 'this is a tiger') post.update!(post_type: Post.types[:whisper], raw: 'this is a tiger')
results = Search.execute('tiger') results = Search.execute('tiger')
@ -920,6 +925,13 @@ describe Search do
results = Search.execute('tiger', guardian: Guardian.new(admin)) results = Search.execute('tiger', guardian: Guardian.new(admin))
expect(results.posts).to eq([post]) expect(results.posts).to eq([post])
results = Search.execute('tiger', guardian: Guardian.new(user))
expect(results.posts).to eq([])
user.groups << whisperers_group
results = Search.execute('tiger', guardian: Guardian.new(user))
expect(results.posts).to eq([post])
end end
end end

View File

@ -834,6 +834,9 @@ describe TopicQuery do
end end
context 'with whispers' do context 'with whispers' do
before do
SiteSetting.enable_whispers = true
end
it 'correctly shows up in unread for staff' do it 'correctly shows up in unread for staff' do

View File

@ -343,6 +343,7 @@ RSpec.describe TopicView do
context '.post_counts_by_user' do context '.post_counts_by_user' do
it 'returns the two posters with their appropriate counts' do it 'returns the two posters with their appropriate counts' do
SiteSetting.enable_whispers = true
Fabricate(:post, topic: topic, user: evil_trout, post_type: Post.types[:whisper]) Fabricate(:post, topic: topic, user: evil_trout, post_type: Post.types[:whisper])
# Should not be counted # Should not be counted
Fabricate(:post, topic: topic, user: evil_trout, post_type: Post.types[:whisper], action_code: 'assign') Fabricate(:post, topic: topic, user: evil_trout, post_type: Post.types[:whisper], action_code: 'assign')
@ -480,6 +481,7 @@ RSpec.describe TopicView do
context 'whispers' do context 'whispers' do
it "handles their visibility properly" do it "handles their visibility properly" do
SiteSetting.enable_whispers = true
p1 = Fabricate(:post, topic: topic, user: evil_trout) p1 = Fabricate(:post, topic: topic, user: evil_trout)
p2 = Fabricate(:post, topic: topic, user: evil_trout, post_type: Post.types[:whisper]) p2 = Fabricate(:post, topic: topic, user: evil_trout, post_type: Post.types[:whisper])
p3 = Fabricate(:post, topic: topic, user: evil_trout) p3 = Fabricate(:post, topic: topic, user: evil_trout)

View File

@ -95,6 +95,7 @@ describe TopicsBulkAction do
context "when the highest_staff_post_number is > highest_post_number for a topic (e.g. whisper is last post)" do context "when the highest_staff_post_number is > highest_post_number for a topic (e.g. whisper is last post)" do
it "dismisses posts" do it "dismisses posts" do
SiteSetting.enable_whispers = true
post1 = create_post(user: user) post1 = create_post(user: user)
p = create_post(topic_id: post1.topic_id) p = create_post(topic_id: post1.topic_id)
create_post(topic_id: post1.topic_id) create_post(topic_id: post1.topic_id)

View File

@ -4,8 +4,9 @@ require 'unread'
describe Unread do describe Unread do
let (:user) { Fabricate.build(:user, id: 1) } let(:whisperers_group) { Fabricate(:group) }
let (:topic) do let(:user) { Fabricate(:user, id: 1, groups: [whisperers_group]) }
let(:topic) do
Fabricate.build(:topic, Fabricate.build(:topic,
posts_count: 13, posts_count: 13,
highest_staff_post_number: 15, highest_staff_post_number: 15,
@ -26,6 +27,7 @@ describe Unread do
describe 'staff counts' do describe 'staff counts' do
it 'should correctly return based on staff post number' do it 'should correctly return based on staff post number' do
SiteSetting.enable_whispers = true
user.admin = true user.admin = true
topic_user.last_read_post_number = 13 topic_user.last_read_post_number = 13
@ -46,11 +48,20 @@ describe Unread do
end end
it 'returns the right unread posts for a staff user' do it 'returns the right unread posts for a staff user' do
SiteSetting.enable_whispers = true
SiteSetting.whispers_allowed_groups = ""
user.admin = true user.admin = true
topic_user.last_read_post_number = 10 topic_user.last_read_post_number = 10
expect(unread.unread_posts).to eq(5) expect(unread.unread_posts).to eq(5)
end end
it 'returns the right unread posts for a whisperer user' do
SiteSetting.enable_whispers = true
SiteSetting.whispers_allowed_groups = "#{whisperers_group.id}"
topic_user.last_read_post_number = 10
expect(unread.unread_posts).to eq(5)
end
it 'should have 0 unread posts if the user has read more posts than exist (deleted)' do it 'should have 0 unread posts if the user has read more posts than exist (deleted)' do
topic_user.last_read_post_number = 14 topic_user.last_read_post_number = 14
expect(unread.unread_posts).to eq(0) expect(unread.unread_posts).to eq(0)

View File

@ -65,6 +65,7 @@ describe PrivateMessageTopicTrackingState do
end end
it 'returns the right tracking state when topics contain whispers' do it 'returns the right tracking state when topics contain whispers' do
SiteSetting.enable_whispers = true
TopicUser.find_by(user: user_2, topic: private_message).update!( TopicUser.find_by(user: user_2, topic: private_message).update!(
last_read_post_number: 1 last_read_post_number: 1
) )

View File

@ -5,7 +5,8 @@ describe Topic do
let(:now) { Time.zone.local(2013, 11, 20, 8, 0) } let(:now) { Time.zone.local(2013, 11, 20, 8, 0) }
fab!(:user) { Fabricate(:user) } fab!(:user) { Fabricate(:user) }
fab!(:user1) { Fabricate(:user) } fab!(:user1) { Fabricate(:user) }
fab!(:user2) { Fabricate(:user) } fab!(:whisperers_group) { Fabricate(:group) }
fab!(:user2) { Fabricate(:user, groups: [whisperers_group]) }
fab!(:moderator) { Fabricate(:moderator) } fab!(:moderator) { Fabricate(:moderator) }
fab!(:coding_horror) { Fabricate(:coding_horror) } fab!(:coding_horror) { Fabricate(:coding_horror) }
fab!(:evil_trout) { Fabricate(:evil_trout) } fab!(:evil_trout) { Fabricate(:evil_trout) }
@ -167,6 +168,11 @@ describe Topic do
context '#visible_post_types' do context '#visible_post_types' do
let(:types) { Post.types } let(:types) { Post.types }
before do
SiteSetting.enable_whispers = true
SiteSetting.whispers_allowed_groups = "#{whisperers_group.id}"
end
it "returns the appropriate types for anonymous users" do it "returns the appropriate types for anonymous users" do
post_types = Topic.visible_post_types post_types = Topic.visible_post_types
@ -186,7 +192,16 @@ describe Topic do
end end
it "returns the appropriate types for staff users" do it "returns the appropriate types for staff users" do
post_types = Topic.visible_post_types(Fabricate.build(:moderator)) post_types = Topic.visible_post_types(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
it "returns the appropriate types for whisperer users" do
post_types = Topic.visible_post_types(user2)
expect(post_types).to include(types[:regular]) expect(post_types).to include(types[:regular])
expect(post_types).to include(types[:moderator_action]) expect(post_types).to include(types[:moderator_action])

View File

@ -3,6 +3,7 @@
describe TopicTrackingState do describe TopicTrackingState do
fab!(:user) { Fabricate(:user) } fab!(:user) { Fabricate(:user) }
fab!(:whisperers_group) { Fabricate(:group) }
let(:post) do let(:post) do
create_post create_post
@ -25,6 +26,21 @@ describe TopicTrackingState do
expect(data["payload"]["archetype"]).to eq(Archetype.default) expect(data["payload"]["archetype"]).to eq(Archetype.default)
end end
it "publishes whisper post to staff users and members of whisperers group" do
whisperers_group = Fabricate(:group)
Fabricate(:user, groups: [whisperers_group])
Fabricate(:topic_user_watching, topic: topic, user: user)
SiteSetting.enable_whispers = true
SiteSetting.whispers_allowed_groups = "#{whisperers_group.id}"
post.update!(post_type: Post.types[:whisper])
message = MessageBus.track_publish("/latest") do
TopicTrackingState.publish_latest(post.topic, true)
end.first
expect(message.group_ids).to contain_exactly(whisperers_group.id, Group::AUTO_GROUPS[:staff])
end
describe 'private message' do describe 'private message' do
it 'should not publish any message' do it 'should not publish any message' do
messages = MessageBus.track_publish do messages = MessageBus.track_publish do
@ -54,6 +70,7 @@ describe TopicTrackingState do
end end
it 'correctly publish read for staff' do it 'correctly publish read for staff' do
SiteSetting.enable_whispers = true
create_post( create_post(
raw: "this is a test post", raw: "this is a test post",
topic: post.topic, topic: post.topic,
@ -118,7 +135,32 @@ describe TopicTrackingState do
expect(message.user_ids).to contain_exactly(other_user.id) expect(message.user_ids).to contain_exactly(other_user.id)
end end
it "publishes whisper post to staff users and members of whisperers group" do
whisperers_group = Fabricate(:group)
Fabricate(:topic_user_watching, topic: topic, user: user)
SiteSetting.enable_whispers = true
SiteSetting.whispers_allowed_groups = "#{whisperers_group.id}"
post.update!(post_type: Post.types[:whisper])
messages = MessageBus.track_publish("/unread") do
TopicTrackingState.publish_unread(post)
end
expect(messages).to eq([])
user.groups << whisperers_group
other_user.grant_admin!
message = MessageBus.track_publish("/unread") do
TopicTrackingState.publish_unread(post)
end.first
expect(message.user_ids).to contain_exactly(user.id, other_user.id)
expect(message.group_ids).to eq(nil)
end
it "does not publish whisper post to non-staff users" do it "does not publish whisper post to non-staff users" do
SiteSetting.enable_whispers = true
post.update!(post_type: Post.types[:whisper]) post.update!(post_type: Post.types[:whisper])
messages = MessageBus.track_publish("/unread") do messages = MessageBus.track_publish("/unread") do
@ -632,6 +674,7 @@ describe TopicTrackingState do
describe ".report" do describe ".report" do
it "correctly reports topics with staff posts" do it "correctly reports topics with staff posts" do
SiteSetting.enable_whispers = true
create_post( create_post(
raw: "this is a test post", raw: "this is a test post",
topic: topic, topic: topic,

View File

@ -2645,4 +2645,37 @@ RSpec.describe User do
expect(result).to be(true) expect(result).to be(true)
end end
end end
describe "#whisperer?" do
before do
SiteSetting.enable_whispers = true
end
it 'returns true for an admin user' do
admin = Fabricate.create(:admin)
expect(admin.whisperer?).to eq(true)
end
it 'returns false for an admin user when whispers are not enabled' do
SiteSetting.enable_whispers = false
admin = Fabricate.create(:admin)
expect(admin.whisperer?).to eq(false)
end
it 'returns true for user belonging to whisperers groups' do
group = Fabricate(:group)
whisperer = Fabricate(:user)
user = Fabricate(:user)
SiteSetting.whispers_allowed_groups = "#{group.id}"
expect(whisperer.whisperer?).to eq(false)
expect(user.whisperer?).to eq(false)
group.add(whisperer)
expect(whisperer.whisperer?).to eq(true)
expect(user.whisperer?).to eq(false)
end
end
end end

View File

@ -3890,6 +3890,7 @@ describe UsersController do
end end
it "includes all post types for staff members" do it "includes all post types for staff members" do
SiteSetting.enable_whispers = true
sign_in(admin) sign_in(admin)
get "/u/#{admin.username}.json", params: { include_post_count_for: topic.id } get "/u/#{admin.username}.json", params: { include_post_count_for: topic.id }

View File

@ -1,12 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe UserPostBookmarkSerializer do RSpec.describe UserPostBookmarkSerializer do
let(:whisperers_group) { Fabricate(:group) }
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:post) { Fabricate(:post, user: user, topic: topic) } let(:post) { Fabricate(:post, user: user, topic: topic) }
let(:topic) { Fabricate(:topic) } let(:topic) { Fabricate(:topic) }
let!(:bookmark) { Fabricate(:bookmark, name: 'Test', user: user, bookmarkable: post) } let!(:bookmark) { Fabricate(:bookmark, name: 'Test', user: user, bookmarkable: post) }
it "uses the correct highest_post_number column based on whether the user is staff" do before do
SiteSetting.enable_whispers = true
SiteSetting.whispers_allowed_groups = "#{whisperers_group.id}"
end
it "uses the correct highest_post_number column based on whether the user is whisperer" do
Fabricate(:post, topic: topic) Fabricate(:post, topic: topic)
Fabricate(:post, topic: topic) Fabricate(:post, topic: topic)
Fabricate(:whisper, topic: topic) Fabricate(:whisper, topic: topic)
@ -16,7 +22,7 @@ RSpec.describe UserPostBookmarkSerializer do
expect(serializer.highest_post_number).to eq(3) expect(serializer.highest_post_number).to eq(3)
user.update!(admin: true) user.groups << whisperers_group
expect(serializer.highest_post_number).to eq(4) expect(serializer.highest_post_number).to eq(4)
end end