FEATURE: Option to disable user presence and profile

This allows users who are privacy conscious to disable the presence
features of the forum as well as their public profile.
This commit is contained in:
Robin Ward 2018-10-10 13:00:08 -04:00
parent fd48ba10b8
commit a566ed42ae
27 changed files with 178 additions and 23 deletions

View File

@ -31,7 +31,8 @@ export default Ember.Controller.extend(PreferencesTabController, {
"disable_jump_reply", "disable_jump_reply",
"automatically_unpin_topics", "automatically_unpin_topics",
"allow_private_messages", "allow_private_messages",
"homepage_id" "homepage_id",
"hide_profile_and_presence"
]; ];
if (makeDefault) { if (makeDefault) {

View File

@ -16,9 +16,9 @@ export default Ember.Controller.extend(CanCheckEmails, {
return currentUser && username === currentUser.get("username"); return currentUser && username === currentUser.get("username");
}, },
@computed("viewingSelf") @computed("viewingSelf", "model.profile_hidden")
canExpandProfile(viewingSelf) { canExpandProfile(viewingSelf, profileHidden) {
return viewingSelf; return !profileHidden && viewingSelf;
}, },
@computed("model.profileBackground") @computed("model.profileBackground")
@ -26,8 +26,11 @@ export default Ember.Controller.extend(CanCheckEmails, {
return !Ember.isEmpty(background.toString()); return !Ember.isEmpty(background.toString());
}, },
@computed("indexStream", "viewingSelf", "forceExpand") @computed("model.profile_hidden", "indexStream", "viewingSelf", "forceExpand")
collapsedInfo(indexStream, viewingSelf, forceExpand) { collapsedInfo(profileHidden, indexStream, viewingSelf, forceExpand) {
if (profileHidden) {
return true;
}
return (!indexStream || viewingSelf) && !forceExpand; return (!indexStream || viewingSelf) && !forceExpand;
}, },

View File

@ -284,7 +284,8 @@ const User = RestModel.extend({
"include_tl0_in_digests", "include_tl0_in_digests",
"theme_ids", "theme_ids",
"allow_private_messages", "allow_private_messages",
"homepage_id" "homepage_id",
"hide_profile_and_presence"
]; ];
if (fields) { if (fields) {

View File

@ -102,6 +102,7 @@ export default function() {
"user", "user",
{ path: "/u/:username", resetNamespace: true }, { path: "/u/:username", resetNamespace: true },
function() { function() {
this.route("profile-hidden");
this.route("summary"); this.route("summary");
this.route( this.route(
"userActivity", "userActivity",

View File

@ -1,6 +1,11 @@
export default Discourse.Route.extend({ export default Discourse.Route.extend({
model() { model() {
return this.modelFor("user"); let user = this.modelFor("user");
if (user.get("profile_hidden")) {
return this.replaceWith("user.profile-hidden");
}
return user;
}, },
setupController(controller, user) { setupController(controller, user) {

View File

@ -2,7 +2,12 @@ export default Discourse.Route.extend({
showFooter: true, showFooter: true,
model() { model() {
return this.modelFor("user").summary(); let user = this.modelFor("user");
if (user.get("profile_hidden")) {
return this.replaceWith("user.profile-hidden");
}
return user.summary();
}, },
actions: { actions: {

View File

@ -37,10 +37,10 @@
{{preference-checkbox labelKey="user.enable_quoting" checked=model.user_option.enable_quoting}} {{preference-checkbox labelKey="user.enable_quoting" checked=model.user_option.enable_quoting}}
{{preference-checkbox labelKey="user.dynamic_favicon" checked=model.user_option.dynamic_favicon}} {{preference-checkbox labelKey="user.dynamic_favicon" checked=model.user_option.dynamic_favicon}}
{{preference-checkbox labelKey="user.disable_jump_reply" checked=model.user_option.disable_jump_reply}} {{preference-checkbox labelKey="user.disable_jump_reply" checked=model.user_option.disable_jump_reply}}
{{#if siteSettings.automatically_unpin_topics}} {{#if siteSettings.automatically_unpin_topics}}
{{preference-checkbox labelKey="user.automatically_unpin_topics" checked=model.user_option.automatically_unpin_topics}} {{preference-checkbox labelKey="user.automatically_unpin_topics" checked=model.user_option.automatically_unpin_topics}}
{{/if}} {{/if}}
{{preference-checkbox labelKey="user.hide_profile_and_presence" checked=model.user_option.hide_profile_and_presence}}
</div> </div>
{{plugin-outlet name="user-preferences-interface" args=(hash model=model save=(action "save"))}} {{plugin-outlet name="user-preferences-interface" args=(hash model=model save=(action "save"))}}

View File

@ -195,8 +195,10 @@
</section> </section>
{{#mobile-nav class='main-nav' desktopClass="nav nav-pills user-nav" currentPath=currentPath}} {{#mobile-nav class='main-nav' desktopClass="nav nav-pills user-nav" currentPath=currentPath}}
<li>{{#link-to 'user.summary'}}{{i18n 'user.summary.title'}}{{/link-to}}</li> {{#unless model.profile_hidden}}
<li>{{#link-to 'userActivity'}}{{i18n 'user.activity_stream'}}{{/link-to}}</li> <li>{{#link-to 'user.summary'}}{{i18n 'user.summary.title'}}{{/link-to}}</li>
<li>{{#link-to 'userActivity'}}{{i18n 'user.activity_stream'}}{{/link-to}}</li>
{{/unless}}
{{#if showNotificationsTab}} {{#if showNotificationsTab}}
<li> <li>
{{#link-to 'userNotifications'}} {{#link-to 'userNotifications'}}

View File

@ -0,0 +1 @@
<p class='user-profile-hidden'>{{i18n "user.profile_hidden"}}</p>

View File

@ -41,6 +41,10 @@
} }
} }
.user-profile-hidden {
font-size: 1.5em;
text-align: center;
}
.user-navigation { .user-navigation {
display: table-cell; display: table-cell;
vertical-align: top; vertical-align: top;

View File

@ -7,6 +7,8 @@ class UserActionsController < ApplicationController
per_chunk = 30 per_chunk = 30
user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)) user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts))
raise Discourse::NotFound unless guardian.can_see_profile?(user)
action_types = (params[:filter] || "").split(",").map(&:to_i) action_types = (params[:filter] || "").split(",").map(&:to_i)
opts = { user_id: user.id, opts = { user_id: user.id,

View File

@ -53,14 +53,18 @@ class UsersController < ApplicationController
include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts) include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)
) )
user_serializer = UserSerializer.new(@user, scope: guardian, root: 'user') user_serializer = nil
if guardian.can_see_profile?(@user)
user_serializer = UserSerializer.new(@user, scope: guardian, root: 'user')
# TODO remove this options from serializer
user_serializer.omit_stats = true
# TODO remove this options from serializer topic_id = params[:include_post_count_for].to_i
user_serializer.omit_stats = true if topic_id != 0
user_serializer.topic_post_count = { topic_id => Post.secured(guardian).where(topic_id: topic_id, user_id: @user.id).count }
topic_id = params[:include_post_count_for].to_i end
if topic_id != 0 else
user_serializer.topic_post_count = { topic_id => Post.secured(guardian).where(topic_id: topic_id, user_id: @user.id).count } user_serializer = HiddenProfileSerializer.new(@user, scope: guardian, root: 'user')
end end
if !params[:skip_track_visit] && (@user != current_user) if !params[:skip_track_visit] && (@user != current_user)
@ -204,8 +208,15 @@ class UsersController < ApplicationController
end end
end end
def profile_hidden
render nothing: true
end
def summary def summary
user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)) user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts))
raise Discourse::NotFound unless guardian.can_see_profile?(user)
summary = UserSummary.new(user, guardian) summary = UserSummary.new(user, guardian)
serializer = UserSummarySerializer.new(summary, scope: guardian) serializer = UserSummarySerializer.new(summary, scope: guardian)
render_json_dump(serializer) render_json_dump(serializer)

View File

@ -42,7 +42,8 @@ class CurrentUserSerializer < BasicUserSerializer
:can_create_topic, :can_create_topic,
:link_posting_access, :link_posting_access,
:external_id, :external_id,
:top_category_ids :top_category_ids,
:hide_profile_and_presence
def link_posting_access def link_posting_access
scope.link_posting_access scope.link_posting_access
@ -68,6 +69,10 @@ class CurrentUserSerializer < BasicUserSerializer
object.user_stat.topic_reply_count object.user_stat.topic_reply_count
end end
def hide_profile_and_presence
object.user_option.hide_profile_and_presence
end
def enable_quoting def enable_quoting
object.user_option.enable_quoting object.user_option.enable_quoting
end end

View File

@ -0,0 +1,7 @@
class HiddenProfileSerializer < BasicUserSerializer
attributes :profile_hidden?
def profile_hidden?
true
end
end

View File

@ -23,6 +23,7 @@ class UserOptionSerializer < ApplicationSerializer
:theme_key_seq, :theme_key_seq,
:allow_private_messages, :allow_private_messages,
:homepage_id, :homepage_id,
:hide_profile_and_presence
def auto_track_topics_after_msecs def auto_track_topics_after_msecs
object.auto_track_topics_after_msecs || SiteSetting.default_other_auto_track_topics_after_msecs object.auto_track_topics_after_msecs || SiteSetting.default_other_auto_track_topics_after_msecs

View File

@ -37,6 +37,7 @@ class UserUpdater
:theme_ids, :theme_ids,
:allow_private_messages, :allow_private_messages,
:homepage_id, :homepage_id,
:hide_profile_and_presence
] ]
def initialize(actor, user) def initialize(actor, user)

View File

@ -618,6 +618,7 @@ en:
private_messages: "Messages" private_messages: "Messages"
activity_stream: "Activity" activity_stream: "Activity"
preferences: "Preferences" preferences: "Preferences"
profile_hidden: "This user's public profile is hidden."
expand_profile: "Expand" expand_profile: "Expand"
collapse_profile: "Collapse" collapse_profile: "Collapse"
bookmarks: "Bookmarks" bookmarks: "Bookmarks"
@ -887,6 +888,7 @@ en:
website: "Web Site" website: "Web Site"
email_settings: "Email" email_settings: "Email"
hide_profile_and_presence: "Hide my public profile and presence features"
like_notification_frequency: like_notification_frequency:
title: "Notify when liked" title: "Notify when liked"
always: "Always" always: "Always"

View File

@ -432,6 +432,7 @@ Discourse::Application.routes.draw do
get "#{root_path}/:username/flagged-posts" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/flagged-posts" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/deleted-posts" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/deleted-posts" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/topic-tracking-state" => "users#topic_tracking_state", constraints: { username: RouteFormat.username } get "#{root_path}/:username/topic-tracking-state" => "users#topic_tracking_state", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/profile-hidden" => "users#profile_hidden"
end end
get "user-badges/:username.json" => "user_badges#username", constraints: { username: RouteFormat.username }, defaults: { format: :json } get "user-badges/:username.json" => "user_badges#username", constraints: { username: RouteFormat.username }, defaults: { format: :json }

View File

@ -0,0 +1,5 @@
class AddHideProfileAndPresenceToUserOptions < ActiveRecord::Migration[5.2]
def change
add_column :user_options, :hide_profile_and_presence, :boolean, default: false, null: false
end
end

View File

@ -93,4 +93,15 @@ module UserGuardian
user && can_administer_user?(user) user && can_administer_user?(user)
end end
def can_see_profile?(user)
return false if user.blank?
# If a user has hidden their profile, restrict it to them and staff
if user.user_option.try(:hide_profile_and_presence?)
return is_me?(user) || is_staff?
end
true
end
end end

View File

@ -113,6 +113,12 @@ export default Ember.Component.extend({
publish(data) { publish(data) {
this._lastPublish = new Date(); this._lastPublish = new Date();
// Don't publish presence if disabled
if (this.currentUser.hide_profile_and_presence) {
return Ember.RSVP.Promise.resolve();
}
return ajax("/presence/publish", { type: "POST", data }); return ajax("/presence/publish", { type: "POST", data });
}, },

View File

@ -107,8 +107,7 @@ after_initialize do
ACTIONS ||= [-"edit", -"reply"].freeze ACTIONS ||= [-"edit", -"reply"].freeze
def publish def publish
raise Discourse::NotFound if current_user.blank? || current_user.user_option.hide_profile_and_presence?
raise Discourse::NotFound if !current_user
data = params.permit( data = params.permit(
:response_needed, :response_needed,

View File

@ -37,6 +37,12 @@ describe ::Presence::PresencesController do
expect { post '/presence/publish.json' }.not_to raise_error expect { post '/presence/publish.json' }.not_to raise_error
end end
it "does not publish for users with disabled presence features" do
user1.user_option.update_column(:hide_profile_and_presence, true)
post '/presence/publish.json'
expect(response.code).to eq("404")
end
it "uses guardian to secure endpoint" do it "uses guardian to secure endpoint" do
private_post = Fabricate(:private_message_post) private_post = Fabricate(:private_message_post)

View File

@ -95,6 +95,41 @@ describe UserGuardian do
expect(guardian.can_pick_avatar?(user_avatar, nil)).to eq(true) expect(guardian.can_pick_avatar?(user_avatar, nil)).to eq(true)
end end
end end
end
describe "#can_see_profile?" do
it "is false for no user" do
expect(Guardian.new.can_see_profile?(nil)).to eq(false)
end
it "is true for a user whose profile is public" do
expect(Guardian.new.can_see_profile?(user)).to eq(true)
end
context "hidden profile" do
let(:hidden_user) do
result = Fabricate(:user)
result.user_option.update_column(:hide_profile_and_presence, true)
result
end
it "is false for another user" do
expect(Guardian.new(user).can_see_profile?(hidden_user)).to eq(false)
end
it "is false for an anonymous user" do
expect(Guardian.new.can_see_profile?(hidden_user)).to eq(false)
end
it "is true for the user themselves" do
expect(Guardian.new(hidden_user).can_see_profile?(hidden_user)).to eq(true)
end
it "is true for a staff user" do
expect(Guardian.new(admin).can_see_profile?(hidden_user)).to eq(true)
end
end
end end
end end

View File

@ -27,7 +27,14 @@ describe UserOption do
user.user_option.expects(:redirected_to_top).returns(nil) user.user_option.expects(:redirected_to_top).returns(nil)
expect(user.user_option.should_be_redirected_to_top).to eq(false) expect(user.user_option.should_be_redirected_to_top).to eq(false)
end end
end
describe "defaults" do
let(:user) { Fabricate(:user) }
it "should not hide the profile and presence by default" do
expect(user.user_option.hide_profile_and_presence).to eq(false)
end
end end
describe "#mailing_list_mode" do describe "#mailing_list_mode" do

View File

@ -9,6 +9,15 @@ describe UserActionsController do
expect(response.status).to eq(400) expect(response.status).to eq(400)
end end
it "returns a 404 for a user with a hidden profile" do
UserActionCreator.enable
post = Fabricate(:post)
post.user.user_option.update_column(:hide_profile_and_presence, true)
get "/user_actions.json", params: { username: post.user.username }
expect(response.code).to eq("404")
end
it 'renders list correctly' do it 'renders list correctly' do
UserActionCreator.enable UserActionCreator.enable
post = Fabricate(:post) post = Fabricate(:post)

View File

@ -2171,6 +2171,14 @@ describe UsersController do
expect(json["user_summary"]["topic_count"]).to eq(1) expect(json["user_summary"]["topic_count"]).to eq(1)
expect(json["user_summary"]["post_count"]).to eq(0) expect(json["user_summary"]["post_count"]).to eq(0)
end end
it "returns 404 for a hidden profile" do
user = Fabricate(:user)
user.user_option.update_column(:hide_profile_and_presence, true)
get "/u/#{user.username_lower}/summary.json"
expect(response.status).to eq(404)
end
end end
describe '#confirm_admin' do describe '#confirm_admin' do
@ -2417,7 +2425,23 @@ describe UsersController do
it "returns success" do it "returns success" do
get "/u/#{user.username}.json" get "/u/#{user.username}.json"
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(JSON.parse(response.body)["user"]["username"]).to eq(user.username) parsed = JSON.parse(response.body)["user"]
expect(parsed['username']).to eq(user.username)
expect(parsed["profile_hidden"]).to be_blank
expect(parsed["trust_level"]).to be_present
end
it "returns a hidden profile" do
user.user_option.update_column(:hide_profile_and_presence, true)
get "/u/#{user.username}.json"
expect(response.status).to eq(200)
parsed = JSON.parse(response.body)["user"]
expect(parsed["username"]).to eq(user.username)
expect(parsed["profile_hidden"]).to eq(true)
expect(parsed["trust_level"]).to be_blank
end end
it "should redirect to login page for anonymous user when profiles are hidden" do it "should redirect to login page for anonymous user when profiles are hidden" do