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
21 changed files with 330 additions and 47 deletions
@@ -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}}
@@ -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
@@ -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);
@@ -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>
@@ -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) }));
});
});
@@ -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);
}
@@ -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);
+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
+4
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
@@ -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
+10
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)
+6
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'
+2
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."
+4
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"
+5
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
@@ -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}}
+22
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
+9
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
+62
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
+6
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"
+21
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