2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-04 17:32:34 +08:00

FEATURE: Make it easier for staff to see if a profile is silenced (#33537)

This commit make it easier for staff to see if a profile is silenced by
aligning it with how suspended notices are shown.

- The number of times a profile has been silenced is shown in the staff
counters banner at the top of the public user profiles.
- Clicking on the silenced note in the staff counters banner goes to a
filtered view of the staff action logs for the user and the silenced
action.
- The profile indicates if a user is silenced and shows the date they
are silenced until. This looks exactly the same as how it currently
displays for suspended users, with the added info of the date. This is
also displayed on the user card, the same as suspended notices currently
are.

## Screenshots


![image](https://github.com/user-attachments/assets/43cf8134-3391-43ff-98fe-fcba07c9e3dd)

![image](https://github.com/user-attachments/assets/2804dcba-70b2-4cf2-8a93-63433a23b481)
This commit is contained in:
Linca 2025-07-14 12:44:31 +08:00 committed by GitHub
parent 96dc65d69c
commit a3cff97b48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 330 additions and 47 deletions

View file

@ -71,7 +71,8 @@ export default class UserCardContents extends CardContentsBase {
@gt("moreBadgesCount", 0) showMoreBadges;
@and("viewingAdmin", "showName", "user.canBeDeleted") showDelete;
@not("user.isBasic") linkWebsite;
@or("user.suspend_reason", "user.bio_excerpt") isSuspendedOrHasBio;
@or("user.suspend_reason", "user.silence_reason") isRestricted;
@or("isRestricted", "user.bio_excerpt") isRestrictedOrHasBio;
@and("user.staged", "canCheckEmails") showCheckEmail;

user = null;
@ -551,7 +552,7 @@ export default class UserCardContents extends CardContentsBase {
</div>
{{/if}}

{{#if this.isSuspendedOrHasBio}}
{{#if this.isRestrictedOrHasBio}}
<div class="card-row second-row">
{{#if this.user.suspend_reason}}
<div class="suspended">
@ -575,15 +576,37 @@ export default class UserCardContents extends CardContentsBase {
>{{this.user.suspend_reason}}</span>
</div>
</div>
{{else}}
{{#if this.user.bio_excerpt}}
<div class="bio">
<HtmlWithLinks>
{{htmlSafe this.user.bio_excerpt}}
</HtmlWithLinks>
</div>
{{/if}}
{{/if}}
{{#if this.user.silence_reason}}
<div class="silenced">
<div class="silence-date">
{{icon "microphone-slash"}}
{{#if this.user.silencedForever}}
{{i18n "user.silenced_permanently"}}
{{else}}
{{i18n
"user.silenced_notice"
date=this.user.silencedTillDate
}}
{{/if}}
</div>
<div class="silence-reason">
<span class="silence-reason-title">{{i18n
"user.silenced_reason"
}}</span>
<span
class="silence-reason-description"
>{{this.user.silence_reason}}</span>
</div>
</div>
{{/if}}
{{#unless this.isRestricted}}
<div class="bio">
<HtmlWithLinks>
{{htmlSafe this.user.bio_excerpt}}
</HtmlWithLinks>
</div>
{{/unless}}
</div>
{{/if}}


View file

@ -27,6 +27,7 @@ export default class UserController extends Controller {
@gt("model.number_of_flags_given", 0) hasGivenFlags;
@gt("model.number_of_flagged_posts", 0) hasFlaggedPosts;
@gt("model.number_of_deleted_posts", 0) hasDeletedPosts;
@gt("model.number_of_silencings", 0) hasBeenSilenced;
@gt("model.number_of_suspensions", 0) hasBeenSuspended;
@gt("model.warnings_received_count", 0) hasReceivedWarnings;
@gt("model.number_of_rejected_posts", 0) hasRejectedPosts;
@ -36,6 +37,7 @@ export default class UserController extends Controller {
"hasGivenFlags",
"hasFlaggedPosts",
"hasDeletedPosts",
"hasBeenSilenced",
"hasBeenSuspended",
"hasReceivedWarnings",
"hasRejectedPosts"
@ -91,9 +93,9 @@ export default class UserController extends Controller {
};
}

@discourseComputed("model.suspended", "currentUser.staff")
isNotSuspendedOrIsStaff(suspended, isStaff) {
return !suspended || isStaff;
@discourseComputed("model.suspended", "model.silenced", "currentUser.staff")
isNotRestrictedOrIsStaff(suspended, silenced, isStaff) {
return (!suspended && !silenced) || isStaff;
}

@discourseComputed("model.trust_level")
@ -210,13 +212,22 @@ export default class UserController extends Controller {
return this.site.desktopView;
}

@action
showSuspensions(event) {
event?.preventDefault();
this.adminTools.showActionLogs(this, {
target_user: this.get("model.username"),
action_name: "suspend_user",
});
get silencingsRouteQuery() {
return {
filters: {
target_user: this.get("model.username"),
action_name: "silence_user",
},
};
}

get suspensionsRouteQuery() {
return {
filters: {
target_user: this.get("model.username"),
action_name: "suspend_user",
},
};
}

@action

View file

@ -431,6 +431,11 @@ export default class User extends RestModel.extend(Evented) {
return isForever(suspendedTill);
}

@discourseComputed("silenced_till")
silenced(silencedTill) {
return silencedTill && moment(silencedTill).isAfter();
}

@discourseComputed("silenced_till")
silencedForever(silencedTill) {
return isForever(silencedTill);

View file

@ -1,5 +1,4 @@
import { array, concat, fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { LinkTo } from "@ember/routing";
import RouteTemplate from "ember-route-template";
import DButton from "discourse/components/d-button";
@ -120,9 +119,28 @@ export default RouteTemplate(
</LinkTo>
</div>
{{/if}}
{{#if @controller.model.number_of_silencings}}
<div>
<LinkTo
@route="adminLogs.staffActionLogs"
@query={{@controller.silencingsRouteQuery}}
>
{{htmlSafe
(i18n
"user.staff_counters.silencings"
className="silencings"
count=@controller.model.number_of_silencings
)
}}
</LinkTo>
</div>
{{/if}}
{{#if @controller.model.number_of_suspensions}}
<div>
<a href {{on "click" @controller.showSuspensions}}>
<LinkTo
@route="adminLogs.staffActionLogs"
@query={{@controller.suspensionsRouteQuery}}
>
{{htmlSafe
(i18n
"user.staff_counters.suspensions"
@ -130,7 +148,7 @@ export default RouteTemplate(
count=@controller.model.number_of_suspensions
)
}}
</a>
</LinkTo>
</div>
{{/if}}
{{#if @controller.model.warnings_received_count}}
@ -278,25 +296,51 @@ export default RouteTemplate(
<div class="bio">
{{#if @controller.model.suspended}}
<div class="suspended">
{{icon "ban"}}
<b>
{{#if @controller.model.suspendedForever}}
{{i18n "user.suspended_permanently"}}
{{else}}
{{i18n
"user.suspended_notice"
date=@controller.model.suspendedTillDate
}}
{{/if}}
</b>
<br />
<div class="suspension-date">
{{icon "ban"}}
<b>
{{#if @controller.model.suspendedForever}}
{{i18n "user.suspended_permanently"}}
{{else}}
{{i18n
"user.suspended_notice"
date=@controller.model.suspendedTillDate
}}
{{/if}}
</b>
</div>
{{#if @controller.model.suspend_reason}}
<b>{{i18n "user.suspended_reason"}}</b>
{{@controller.model.suspend_reason}}
<div class="suspension-reason">
<b>{{i18n "user.suspended_reason"}}</b>
{{@controller.model.suspend_reason}}
</div>
{{/if}}
</div>
{{/if}}
{{#if @controller.isNotSuspendedOrIsStaff}}
{{#if @controller.model.silenced}}
<div class="silenced">
<div class="silence-date">
{{icon "microphone-slash"}}
<b>
{{#if @controller.model.silencedForever}}
{{i18n "user.silenced_permanently"}}
{{else}}
{{i18n
"user.silenced_notice"
date=@controller.model.silencedTillDate
}}
{{/if}}
</b>
</div>
{{#if @controller.model.silence_reason}}
<div class="silence-reason">
<b>{{i18n "user.silenced_reason"}}</b>
{{@controller.model.silence_reason}}
</div>
{{/if}}
</div>
{{/if}}
{{#if @controller.isNotRestrictedOrIsStaff}}
<HtmlWithLinks>
{{htmlSafe @controller.model.bio_cooked}}
</HtmlWithLinks>

View file

@ -1,6 +1,7 @@
import { getOwner } from "@ember/owner";
import { click, currentURL, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { longDate } from "discourse/lib/formatter";
import { cloneJSON } from "discourse/lib/object";
import userFixtures from "discourse/tests/fixtures/user-fixtures";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
@ -172,3 +173,37 @@ acceptance("User Card - Inactive user", function (needs) {
assert.dom(".user-card .inactive-user").hasText(i18n("user.inactive_user"));
});
});

acceptance("User Card - Restricted user", function (needs) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);

needs.user();
needs.pretender((server, helper) => {
const cardResponse = cloneJSON(userFixtures["/u/eviltrout/card.json"]);
cardResponse.user.silence_reason = "silenced for testing";
cardResponse.user.silenced_till = tomorrow.toISOString();
cardResponse.user.suspend_reason = "suspended for testing";
cardResponse.user.suspended_till = tomorrow.toISOString();
server.get("/u/eviltrout/card.json", () => helper.response(cardResponse));
});

test("it shows restricted user information", async function (assert) {
await visit("/t/this-is-a-test-topic/9");
await click('a[data-user-card="eviltrout"]');

assert
.dom(".user-card .card-row .silence-reason")
.hasText(i18n("user.silenced_reason") + "silenced for testing");
assert
.dom(".user-card .card-row .silence-date")
.hasText(i18n("user.silenced_notice", { date: longDate(tomorrow) }));

assert
.dom(".user-card .card-row .suspension-reason")
.hasText(i18n("user.suspended_reason") + "suspended for testing");
assert
.dom(".user-card .card-row .suspension-date")
.hasText(i18n("user.suspended_notice", { date: longDate(tomorrow) }));
});
});

View file

@ -212,6 +212,10 @@
color: var(--danger);
}

.silenced {
color: var(--danger);
}

.primary {
width: 100%;
position: relative;
@ -338,6 +342,10 @@
background-color: var(--danger-medium);
}

.silencings {
background-color: var(--danger-medium);
}

.suspensions {
background-color: var(--danger);
}

View file

@ -225,6 +225,15 @@
}
}

.silenced {
color: var(--danger);

.silence-reason-title,
.silence-date {
font-weight: bold;
}
}

.profile-hidden,
.inactive-user {
font-size: var(--font-up-1);

View file

@ -1523,6 +1523,7 @@ class UsersController < ApplicationController
number_of_deleted_posts
number_of_flagged_posts
number_of_flags_given
number_of_silencings
number_of_suspensions
warnings_received_count
number_of_rejected_posts

View file

@ -1632,6 +1632,10 @@ class User < ActiveRecord::Base
.count
end

def number_of_silencings
UserHistory.for(self, :silence_user).count
end

def number_of_suspensions
UserHistory.for(self, :suspend_user).count
end

View file

@ -20,7 +20,6 @@ class AdminUserListSerializer < BasicUserSerializer
:approved,
:suspended_at,
:suspended_till,
:silenced,
:silenced_till,
:time_read,
:staged,
@ -44,14 +43,6 @@ class AdminUserListSerializer < BasicUserSerializer
alias_method :include_secondary_emails?, :include_email?
alias_method :include_associated_accounts?, :include_email?

def silenced
object.silenced?
end

def include_silenced?
object.silenced?
end

def silenced_till
object.silenced_till
end

View file

@ -62,6 +62,8 @@ class UserCardSerializer < BasicUserSerializer
:title,
:suspend_reason,
:suspended_till,
:silence_reason,
:silenced_till,
:badge_count,
:user_fields,
:custom_fields,
@ -158,6 +160,14 @@ class UserCardSerializer < BasicUserSerializer
object.suspended?
end

def include_silence_reason?
scope.can_see_silencing_reason?(object) && object.silenced?
end

def include_silenced_till?
object.silenced?
end

def user_fields
allowed_keys = scope.allowed_user_field_ids(object)
object.user_fields(allowed_keys)

View file

@ -1479,6 +1479,9 @@ en:
suspended_notice: "This user is suspended until %{date}."
suspended_permanently: "This user is suspended."
suspended_reason: "Reason: "
silenced_notice: "This user is silenced until %{date}."
silenced_permanently: "This user is silenced."
silenced_reason: "Reason: "
github_profile: "GitHub"
email_activity_summary: "Activity Summary"
mailing_list_mode:
@ -1551,6 +1554,9 @@ en:
deleted_posts:
one: '<span class="%{className}">%{count}</span> deleted post'
other: '<span class="%{className}">%{count}</span> deleted posts'
silencings:
one: '<span class="%{className}">%{count}</span> silenced'
other: '<span class="%{className}">%{count}</span> silenced'
suspensions:
one: '<span class="%{className}">%{count}</span> suspension'
other: '<span class="%{className}">%{count}</span> suspensions'

View file

@ -2428,6 +2428,8 @@ en:

show_inactive_accounts: "Allow logged in users to browse profiles of inactive accounts."

hide_silencing_reasons: "Don't display silencing reasons publically on user profiles."

hide_suspension_reasons: "Don't display suspension reasons publically on user profiles."

log_personal_messages_views: "Log personal message views by Admin for other users/groups."

View file

@ -960,6 +960,10 @@ users:
default: false
client: true
area: "users"
hide_silencing_reasons:
default: false
client: true
area: "users"
log_personal_messages_views:
default: false
area: "users"

View file

@ -115,6 +115,11 @@ module UserGuardian
user == @user || is_staff?
end

def can_see_silencing_reason?(user)
return true unless SiteSetting.hide_silencing_reasons?
user == @user || is_staff?
end

def can_disable_second_factor?(user)
user && can_administer_user?(user)
end

View file

@ -288,6 +288,11 @@ const UserAbout = <template>
</span>&nbsp;{{i18n "user.staff_counters.deleted_posts"}}
</a>
</div>
<div>
<span class="silencings">
{{@dummy.user.num_of_silencings}}
</span>&nbsp;{{i18n "user.staff_counters.silencings"}}
</div>
<div>
<span class="suspensions">
{{@dummy.user.number_of_suspensions}}

View file

@ -4227,6 +4227,28 @@ RSpec.describe Guardian do
end
end

describe "silencing reasons" do
it "will be shown by default" do
expect(Guardian.new.can_see_silencing_reason?(user)).to eq(true)
end

context "with hide silencing reason enabled" do
before { SiteSetting.hide_silencing_reasons = true }

it "will not be shown to anonymous users" do
expect(Guardian.new.can_see_silencing_reason?(user)).to eq(false)
end

it "users can see their own silencings" do
expect(Guardian.new(user).can_see_silencing_reason?(user)).to eq(true)
end

it "staff can see silencings" do
expect(Guardian.new(moderator).can_see_silencing_reason?(user)).to eq(true)
end
end
end

describe "#can_remove_allowed_users?" do
context "with staff users" do
it "should be true" do

View file

@ -2126,6 +2126,15 @@ RSpec.describe User do
expect(user.number_of_flagged_posts).to eq(0)
end
end

describe "#number_of_silencings" do
it "counts the number of silencings" do
3.times do
Fabricate(:user_history, action: UserHistory.actions[:silence_user], target_user: user)
end
expect(user.number_of_silencings).to eq(3)
end
end
end

describe "new_user?" do

View file

@ -7881,6 +7881,68 @@ RSpec.describe UsersController do
end
end

describe "#staff_info" do
context "when logged out" do
it "responds with 403" do
get "/u/#{user.username}/staff-info.json"
expect(response.status).to eq(403)
end
end

context "when logged in" do
before do
Fabricate(:user_history, action: UserHistory.actions[:silence_user], target_user: user)
Fabricate(:user_history, action: UserHistory.actions[:suspend_user], target_user: user)
Fabricate(:reviewable_flagged_post, target_created_by: user)
end

it "responds with 403 for normal users" do
sign_in(user)
get "/u/#{user.username}/staff-info.json"
expect(response.status).to eq(403)
end

it "responds with 200 for moderators" do
sign_in(moderator)

get "/u/#{user.username}/staff-info.json"
expect(response.status).to eq(200)
end

it "responds with 200 for admins" do
sign_in(admin)

get "/u/#{user.username}/staff-info.json"
expect(response.status).to eq(200)
end

it "delegates work to `User`" do
user_instance = mock
UsersController.any_instance.stubs(:fetch_user_from_params).returns(user_instance)

result = {}

%i[
number_of_deleted_posts
number_of_flagged_posts
number_of_flags_given
number_of_silencings
number_of_suspensions
warnings_received_count
number_of_rejected_posts
].each do |info|
user_instance.expects(info).returns(user.public_send(info))
result[info.to_s] = user.public_send(info)
end

sign_in(admin)

get "/u/#{user.username}/staff-info.json"
expect(response.parsed_body).to eq(result)
end
end
end

def create_second_factor_security_key
sign_in(user1)
stub_secure_session_confirmed

View file

@ -46,6 +46,12 @@ module PageObjects
self
end

def click_staff_info_silencings_link
staff_counters = page.find(".staff-counters")
staff_counters.find("a:has(.silencings)").click
self
end

def expand_info_panel
button = page.find("button[aria-controls='collapsed-info-panel']")
button.click if button["aria-expanded"] == "false"

View file

@ -17,4 +17,25 @@ describe "Viewing user staff info as an admin", type: :system do
expect(user_page).to have_warning_messages_path(user)
end
end

context "for silencings" do
fab!(:silencing) do
Fabricate(:user_history, action: UserHistory.actions[:silence_user], target_user: user)
end
it "should display the right link to user's silencings with the right count in text" do
user_page.visit(user)
silencings_counters = page.find(".staff-counters .silencings")
expect(silencings_counters).to have_text("1")

user_page.click_staff_info_silencings_link
expect(user_page).to have_current_path("/admin/logs/staff_action_logs", ignore_query: true)

current_url = user_page.current_url
uri = URI.parse(current_url)
filters = JSON.parse(URI.decode_www_form(uri.query).to_h["filters"])

expect(filters["target_user"]).to eq(user.username)
expect(filters["action_name"]).to eq("silence_user")
end
end
end