feat: add admin moderation actions for abuse reports and for reported abusive content (#8716)

- Implementation of milestone 6. from **Task F. Moderation features: Reporting** (part of [amendment of the workplan](https://codeberg.org/forgejo/sustainability/src/branch/main/2022-12-01-nlnet/2025-02-07-extended-workplan.md#task-f-moderation-features-reporting) for NLnet 2022-12-035):
  `6. Forgejo admins can perform common actions on the listed reports (content deletion, locking of user account)`

---

Follow-up of !7905 (and !6977)

---

This adds some action buttons within the _Moderation reports_ section (/admin/moderation/reports) within the _Site administration_ page, so that administrators can:
- mark a report as Handled or as Ignored (without performing any action on the reported content);
- mark a user account as suspended (set `prohibit_login` = true);
- delete (and purge) a user / organization and mark the linked reports as Handled;
- delete a repository and mark the linked reports as Handled;
- delete an issue / pull request and mark the linked reports as Handled;
- delete a comment and mark the linked reports as Handled;

The buttons were added on the sight side of each report from the overview, below the existing counter (that show how many times the content was reported and opens the details page). Only the buttons for updating the status of the report are directly visible - as `✓` and `✗` icons with some tooltips - while the content actions are hidden under a `⋯` dropdown.
The implementation was done using HTMX so that the page is not refreshed after each action.

Some discussions regarding the UI/UX started with https://codeberg.org/forgejo/design/issues/30#issuecomment-5958634

### Manual testing
- First make sure that moderation in enabled ([moderation] ENABLED config is set as true within app.ini).
- Report multiple users, organizations, repositories, issues, pull requests and comments.
- Go to _Moderation reports_ overview section section and make sure the buttons are visible;
  - The `✓` and `✗` should be available for each shown report;
  - The horizontal dropdown menu (`⋯`) should not be visible for reports linked to already deleted content.
- The actions available within the dropdown menu should correspond to the reported content type (e.g. 'Suspend account' and 'Delete account' for users/organizations, 'Delete repository' for repositories, etc.).
- When an action is successful a flash message should be displayed above the overview.
- Warnings should be displayed (as flash messages) when trying to suspend or delete your account (in case someone reported you) or an organization.
- An info (flash message) should be displayed when trying to suspend a user that is already suspended.
- Mark a report as Handled / Ignored and observe that a success flash message confirms the action and the report is removed from the list without reloading the page;
  - Refresh the page to make sure the report will not be loaded again (also check in the DB that the status was updated and the resolved timestamp is correctly set).
- Suspend a user account and make sure the report remains in the list (it is not resolved);
  - Make sure the above user gets the 'Suspended account' notice after login.
- Delete a user account and observe that a success flash message confirms the action and the report is removed from the list without reloading the page;
  - Make sure that all owned organizations and repositories as well as all the issues, PRs and comments posted in other repositories were deleted;
  - Make sure the linked abuse reports are marked as Handled (and resolved timestamp is set).
- Delete an organization and make sure that owned repositories were also deleted.
- Similarly, delete a repository / issue / PR / comment and check that the contents are not available any more and the linked reports are resolved.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8716
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: floss4good <floss4good@disroot.org>
Co-committed-by: floss4good <floss4good@disroot.org>
This commit is contained in:
floss4good 2025-12-03 20:07:32 +01:00 committed by Gusted
parent 6035d6c825
commit d5fa12ded1
8 changed files with 391 additions and 10 deletions

View file

@ -183,7 +183,6 @@ func GetResolvedReports(ctx context.Context, keepReportsFor time.Duration) ([]*A
Find(&abuseReports)
}
/*
// MarkAsHandled will change the status to 'Handled' for all reports linked to the same item (user, repository, issue or comment).
func MarkAsHandled(ctx context.Context, contentType ReportedContentType, contentID int64) error {
return updateStatus(ctx, contentType, contentID, ReportStatusTypeHandled)
@ -199,8 +198,7 @@ func updateStatus(ctx context.Context, contentType ReportedContentType, contentI
_, err := db.GetEngine(ctx).Where(builder.Eq{
"content_type": contentType,
"content_id": contentID,
}).Cols("status").Update(&AbuseReport{Status: status})
}).Update(&AbuseReport{Status: status, ResolvedUnix: timeutil.TimeStampNow()})
return err
}
*/

View file

@ -50,6 +50,22 @@ func (ard AbuseReportDetailed) ContentURL() string {
}
}
func (ard AbuseReportDetailed) ReportedContentIsUser() bool {
return ard.ContentType == ReportedContentTypeUser
}
func (ard AbuseReportDetailed) ReportedContentIsRepo() bool {
return ard.ContentType == ReportedContentTypeRepository
}
func (ard AbuseReportDetailed) ReportedContentIsIssue() bool {
return ard.ContentType == ReportedContentTypeIssue
}
func (ard AbuseReportDetailed) ReportedContentIsComment() bool {
return ard.ContentType == ReportedContentTypeComment
}
func GetOpenReports(ctx context.Context) ([]*AbuseReportDetailed, error) {
var reports []*AbuseReportDetailed
@ -59,6 +75,7 @@ func GetOpenReports(ctx context.Context) ([]*AbuseReportDetailed, error) {
// - Escaping can be done with double quotes (") or backticks (`).
// - For MariaDB/MySQL there is no need to escape the above.
// - Therefore we will use double quotes (") but only for PostgreSQL and SQLite.
// - Also, note that builder.Union() is broken: gitea.com/xorm/builder/issues/71
identifierEscapeChar := ``
if setting.Database.Type.IsPostgreSQL() || setting.Database.Type.IsSQLite3() {
identifierEscapeChar = `"`
@ -113,8 +130,8 @@ func GetOpenReportsByTypeAndContentID(ctx context.Context, contentType ReportedC
// Some remarks concerning PostgreSQL:
// - user table should be escaped (e.g. `user`);
// - tried to use aliases for table names but errors like 'invalid reference to FROM-clause entry'
// or 'missing FROM-clause entry' were returned;
// - tried to use aliases for table names but errors like 'pq: invalid reference to FROM-clause entry'
// or 'pq: missing FROM-clause entry' were returned;
err := db.GetEngine(ctx).
Select("abuse_report.*, `user`.name AS reporter_name, abuse_report_shadow_copy.created_unix AS shadow_copy_date, abuse_report_shadow_copy.raw_value AS shadow_copy_raw_value").
Table("abuse_report").

View file

@ -122,6 +122,22 @@
"admin.moderation.reports": "Reports",
"admin.moderation.no_open_reports": "There are currently no open reports.",
"admin.moderation.deleted_content_ref": "Reported content with type %[1]v and id %[2]d no longer exists",
"moderation.report.mark_as_handled": "Mark as handled",
"moderation.report.mark_as_ignored": "Mark as ignored",
"moderation.action.account.delete": "Delete account",
"moderation.action.account.suspend": "Suspend account",
"moderation.action.repo.delete": "Delete repository",
"moderation.action.issue.delete": "Delete issue",
"moderation.action.comment.delete": "Delete comment",
"moderation.unknown_action": "Unknown action",
"moderation.users.cannot_suspend_self": "You cannot suspend yourself.",
"moderation.users.cannot_suspend_admins": "Users with admin privileges cannot be suspended.",
"moderation.users.cannot_suspend_org": "Organizations cannot be suspended.",
"moderation.users.already_suspended": "User account is already suspended.",
"moderation.users.suspend_success": "The user account has been suspended.",
"moderation.users.cannot_delete_admins": "Users with admin privileges cannot be deleted.",
"moderation.issue.deletion_success": "The issue has been deleted.",
"moderation.comment.deletion_success": "The comment has been deleted.",
"moderation.report_abuse": "Report abuse",
"moderation.report_content": "Report content",
"moderation.report_abuse_form.header": "Report abuse to administrator",

View file

@ -9,16 +9,24 @@ import (
"forgejo.org/models/issues"
"forgejo.org/models/moderation"
"forgejo.org/models/organization"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/user"
"forgejo.org/modules/base"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/services/context"
issue_service "forgejo.org/services/issue"
moderation_service "forgejo.org/services/moderation"
org_service "forgejo.org/services/org"
repo_service "forgejo.org/services/repository"
user_service "forgejo.org/services/user"
)
const (
tplModerationReports base.TplName = "admin/moderation/reports"
tplModerationReportDetails base.TplName = "admin/moderation/report_details"
tplAlert base.TplName = "base/alert"
)
// AbuseReports renders the reports overview page from admin moderation section.
@ -36,6 +44,17 @@ func AbuseReports(ctx *context.Context) {
ctx.Data["AbuseCategories"] = moderation.AbuseCategoriesTranslationKeys
ctx.Data["GhostUserName"] = user.GhostUserName
// available actions that can be done for reports
ctx.Data["MarkAsHandled"] = int(moderation_service.ReportActionMarkAsHandled)
ctx.Data["MarkAsIgnored"] = int(moderation_service.ReportActionMarkAsIgnored)
// available actions that can be done for reported content
ctx.Data["ActionSuspendAccount"] = int(moderation_service.ContentActionSuspendAccount)
ctx.Data["ActionDeleteAccount"] = int(moderation_service.ContentActionDeleteAccount)
ctx.Data["ActionDeleteRepo"] = int(moderation_service.ContentActionDeleteRepo)
ctx.Data["ActionDeleteIssue"] = int(moderation_service.ContentActionDeleteIssue)
ctx.Data["ActionDeleteComment"] = int(moderation_service.ContentActionDeleteComment)
ctx.HTML(http.StatusOK, tplModerationReports)
}
@ -155,3 +174,222 @@ func setReportedContentDetails(ctx *context.Context, report *moderation.AbuseRep
ctx.Data["Poster"] = poster
return nil
}
func PerformAction(ctx *context.Context) {
var contentID int64
var contentType moderation.ReportedContentType
contentID = ctx.FormInt64("content_id")
if contentID <= 0 {
ctx.Error(http.StatusBadRequest, "Invalid parameter: content_id")
return
}
contentType = moderation.ReportedContentType(ctx.FormInt64("content_type"))
if !contentType.IsValid() {
ctx.Error(http.StatusBadRequest, "Invalid parameter: content_type")
return
}
reportAction := moderation_service.ReportAction(ctx.FormInt64("report_action"))
if !reportAction.IsValid() {
ctx.Error(http.StatusBadRequest, "Invalid parameter: report_action")
return
}
contentAction := moderation_service.ContentAction(ctx.FormInt64("content_action"))
if !contentAction.IsValid() {
ctx.Error(http.StatusBadRequest, "Invalid parameter: content_action")
return
}
if contentAction == moderation_service.ContentActionNone && reportAction == moderation_service.ReportActionNone {
ctx.Error(http.StatusBadRequest, "Invalid combination of content_action and report_action parameters")
return
}
switch contentAction {
case moderation_service.ContentActionNone:
updateReportStatus(ctx, contentType, contentID, reportAction)
case moderation_service.ContentActionSuspendAccount:
suspendAccount(ctx, contentType, contentID, reportAction)
case moderation_service.ContentActionDeleteAccount:
deleteAccount(ctx, contentType, contentID, reportAction)
case moderation_service.ContentActionDeleteRepo:
deleteRepository(ctx, contentType, contentID, reportAction)
case moderation_service.ContentActionDeleteIssue:
deleteIssue(ctx, contentType, contentID, reportAction)
case moderation_service.ContentActionDeleteComment:
deleteComment(ctx, contentType, contentID, reportAction)
default:
ctx.Flash.Warning(ctx.Tr("moderation.unknown_action"), true)
ctx.HTML(http.StatusOK, tplAlert)
}
}
func updateReportStatus(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) {
var err error
switch reportAction {
case moderation_service.ReportActionMarkAsHandled:
err = moderation.MarkAsHandled(ctx, contentType, contentID)
case moderation_service.ReportActionMarkAsIgnored:
err = moderation.MarkAsIgnored(ctx, contentType, contentID)
default:
return
}
if err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to update the status of the report: %s", err.Error()))
return
}
// TODO: translate and maybe use a more specific message (e.g. saying that the status was changed to 'Handled' or 'Ignored')?
ctx.Flash.Success(fmt.Sprintf("Status updated for report(s) with type #%d and id #%d", contentType, contentID), true)
ctx.HTML(http.StatusOK, tplAlert)
}
func suspendAccount(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) {
if contentID == ctx.Doer.ID {
ctx.Flash.Warning(ctx.Tr("moderation.users.cannot_suspend_self"), true)
ctx.HTML(http.StatusOK, tplAlert)
return
}
reportedUser, err := user.GetUserByID(ctx, contentID)
if err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the user: %s", err.Error()))
return
}
if reportedUser.IsAdmin {
ctx.Flash.Warning(ctx.Tr("moderation.users.cannot_suspend_admins"), true)
ctx.HTML(http.StatusOK, tplAlert)
return
}
if reportedUser.IsOrganization() {
ctx.Flash.Warning(ctx.Tr("moderation.users.cannot_suspend_org"), true)
ctx.HTML(http.StatusOK, tplAlert)
return
}
if reportedUser.ProhibitLogin {
ctx.Flash.Info(ctx.Tr("moderation.users.already_suspended"), true)
ctx.HTML(http.StatusOK, tplAlert)
return
}
authOpts := &user_service.UpdateAuthOptions{ProhibitLogin: optional.Some(true)}
// TODO: should we implement a new, simpler, SuspendAccount() method?!
if err = user_service.UpdateAuth(ctx, reportedUser, authOpts); err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to suspend the user: %s", err.Error()))
return
}
if reportAction != moderation_service.ReportActionNone {
// TODO: currently not implemented
updateReportStatus(ctx, contentType, contentID, reportAction)
}
ctx.Flash.Success(ctx.Tr("moderation.users.suspend_success"), true)
ctx.HTML(http.StatusOK, tplAlert)
}
func deleteAccount(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) {
if contentID == ctx.Doer.ID {
ctx.Resp.Header().Add("HX-Reswap", "none") // prevent removing the report from the list
ctx.Flash.Warning(ctx.Tr("admin.users.cannot_delete_self"), true)
ctx.HTML(http.StatusOK, tplAlert)
return
}
reportedUser, err := user.GetUserByID(ctx, contentID)
if err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the user: %s", err.Error()))
return
}
if reportedUser.IsAdmin {
ctx.Resp.Header().Add("HX-Reswap", "none") // prevent removing the report from the list
ctx.Flash.Warning(ctx.Tr("moderation.users.cannot_delete_admins"), true)
ctx.HTML(http.StatusOK, tplAlert)
return
}
if reportedUser.IsOrganization() {
reportedOrg := organization.OrgFromUser(reportedUser)
if err = org_service.DeleteOrganization(ctx, reportedOrg, true); err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the organization: %s", err.Error()))
return
}
log.Trace("Organization deleted by admin (%s): %s", ctx.Doer.Name, reportedOrg.Name)
} else {
if err = user_service.DeleteUser(ctx, reportedUser, true); err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the user: %s", err.Error()))
return
}
log.Trace("Account deleted by admin (%s): %s", ctx.Doer.Name, reportedUser.Name)
}
// TODO: when deleting content maybe we should always mark the reports as handled (does it makes sense to keep them open?!)
updateReportStatus(ctx, contentType, contentID, reportAction) // TODO: combine success messages
ctx.Flash.Success(ctx.Tr("admin.users.deletion_success"), true)
ctx.HTML(http.StatusOK, tplAlert)
}
func deleteRepository(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) {
repo, err := repo_model.GetRepositoryByID(ctx, contentID)
if err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the repository: %s", err.Error()))
return
}
if err = repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the repository: %s", err.Error()))
return
}
log.Trace("Repository deleted: %s", repo.FullName())
updateReportStatus(ctx, contentType, contentID, reportAction)
ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success"), true)
ctx.HTML(http.StatusOK, tplAlert)
}
func deleteIssue(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) {
issue, err := issues.GetIssueByID(ctx, contentID)
if err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the issue: %s", err.Error()))
return
}
if err = issue_service.DeleteIssue(ctx, ctx.Doer, nil, issue); err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the issue: %s", err.Error()))
return
}
updateReportStatus(ctx, contentType, contentID, reportAction)
ctx.Flash.Success(ctx.Tr("moderation.issue.deletion_success"), true)
ctx.HTML(http.StatusOK, tplAlert)
}
func deleteComment(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) {
comment, err := issues.GetCommentByID(ctx, contentID)
if err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the comment: %s", err.Error()))
return
}
if err = issue_service.DeleteComment(ctx, ctx.Doer, comment); err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the comment: %s", err.Error()))
return
}
updateReportStatus(ctx, contentType, contentID, reportAction)
ctx.Flash.Success(ctx.Tr("moderation.comment.deletion_success"), true)
ctx.HTML(http.StatusOK, tplAlert)
}

View file

@ -822,6 +822,7 @@ func registerRoutes(m *web.Route) {
m.Get("", admin.AbuseReports)
m.Get("/type/{type:1|2|3|4}/id/{id}", admin.AbuseReportDetails)
})
m.Post("/abuse_reports/act", admin.PerformAction)
}
}, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "EnableModeration", setting.Moderation.Enabled))
// ***** END: Admin *****

View file

@ -4,6 +4,8 @@
package moderation
import (
"slices"
"forgejo.org/models/issues"
"forgejo.org/models/moderation"
"forgejo.org/models/repo"
@ -13,6 +15,50 @@ import (
"forgejo.org/services/context"
)
type ReportAction int
const (
ReportActionNone ReportAction = iota
ReportActionMarkAsHandled
ReportActionMarkAsIgnored
)
var allReportActions = []ReportAction{
ReportActionNone,
ReportActionMarkAsHandled,
ReportActionMarkAsIgnored,
}
func (ra ReportAction) IsValid() bool {
return slices.Contains(allReportActions, ra)
}
type ContentAction int
const (
// ContentActionNone means that no action should be done for the reported content itself;
// is should be used when needed to just update the status of the report.
ContentActionNone ContentAction = iota
ContentActionSuspendAccount
ContentActionDeleteAccount
ContentActionDeleteRepo
ContentActionDeleteIssue
ContentActionDeleteComment
)
var allContentActions = []ContentAction{
ContentActionNone,
ContentActionSuspendAccount,
ContentActionDeleteAccount,
ContentActionDeleteRepo,
ContentActionDeleteIssue,
ContentActionDeleteComment,
}
func (ca ContentAction) IsValid() bool {
return slices.Contains(allContentActions, ca)
}
// GetShadowCopyMap unmarshals the shadow copy raw value of the given abuse report and returns a list of <key, value> pairs
// (to be rendered when the report is reviewed by an admin).
// If the report does not have a shadow copy ID or the raw value is empty, returns nil.

View file

@ -19,7 +19,9 @@
</div>
</div>
{{range .Reports}}
<div class="flex-item report" id="report-{{.ID}}">
{{$reportDivID := print "report-" .ID}}
{{$reportDivSelector := print "#" $reportDivID}}
<div class="flex-item report" id="{{$reportDivID}}">
<div class="flex-item-leading">
{{svg .ContentTypeIconName 24}}
</div>
@ -50,11 +52,67 @@
<span class="text truncate">{{ctx.Locale.Tr "moderation.report_remarks"}}: <em>{{.Remarks}}</em></span>
</div>
</div>
<a href="{{AppSubUrl}}/admin/moderation/reports/type/{{.ContentType}}/id/{{.ContentID}}">
{{$postURL := print AppSubUrl "/admin/abuse_reports/act"}}
<div class="flex-item-trailing">
<span class="tw-text-16 tw-font-semibold">{{.ReportedTimes}}</span>{{svg "octicon-report" "tw-ml-2"}}
<div class="flex-item-main tw-max-w-12 sm:tw-max-w-40">
<a class="flex-item-trailing tw-p-1" href="{{AppSubUrl}}/admin/moderation/reports/type/{{.ContentType}}/id/{{.ContentID}}">
<span class="tw-text-16 tw-font-semibold">{{.ReportedTimes}}</span>{{svg "octicon-report" "tw-ml-2"}}
</a>
<div class="button-sequence right tw-items-center"
hx-target="{{$reportDivSelector}}" hx-indicator="{{$reportDivSelector}}"
hx-vals='{"content_type": {{.ContentType}}, "content_id": {{.ContentID}}}'>
<button class="item tw-p-1 ui button" data-tooltip-content="{{ctx.Locale.Tr "moderation.report.mark_as_handled"}}"
hx-post="{{$postURL}}" hx-vals='{"report_action": {{$.MarkAsHandled}}}'>
{{svg "octicon-check"}}
</button>
<button class="item tw-p-1 ui button" data-tooltip-content="{{ctx.Locale.Tr "moderation.report.mark_as_ignored"}}"
hx-post="{{$postURL}}" hx-vals='{"report_action": {{$.MarkAsIgnored}}}'>
{{svg "octicon-x"}}
</button>
{{if .ContentReference}} {{/* the reference is missing when the content was already deleted */}}
<details class="item dropdown dir-rtl">
<summary class="tw-p-1" data-tooltip-content="{{ctx.Locale.Tr "profile.actions.tooltip"}}">{{svg "octicon-kebab-horizontal"}}</summary>
<ul
hx-target="{{$reportDivSelector}}" hx-indicator="{{$reportDivSelector}}"
hx-vals='{"content_type": {{.ContentType}}, "content_id": {{.ContentID}}}'>
{{if .ReportedContentIsUser}}
<li>
<a hx-post="{{$postURL}}" hx-vals='{"content_action": {{$.ActionSuspendAccount}}}' hx-swap="none" class="item">
{{svg "octicon-x"}}{{ctx.Locale.Tr "moderation.action.account.suspend"}}
</a>
</li>
<li>
<a hx-post="{{$postURL}}" hx-vals='{"content_action": {{$.ActionDeleteAccount}}, "report_action": {{$.MarkAsHandled}}}' class="item">
{{svg "octicon-trash"}}{{ctx.Locale.Tr "moderation.action.account.delete"}}
</a>
</li>
{{else if .ReportedContentIsRepo}}
<li>
<a hx-post="{{$postURL}}" hx-vals='{"content_action": {{$.ActionDeleteRepo}}, "report_action": {{$.MarkAsHandled}}}' class="item">
{{svg "octicon-trash"}}{{ctx.Locale.Tr "moderation.action.repo.delete"}}
</a>
</li>
{{else if .ReportedContentIsIssue}}
<li>
<a hx-post="{{$postURL}}" hx-vals='{"content_action": {{$.ActionDeleteIssue}}, "report_action": {{$.MarkAsHandled}}}' class="item">
{{svg "octicon-trash"}}{{ctx.Locale.Tr "moderation.action.issue.delete"}}
</a>
</li>
{{else if .ReportedContentIsComment}}
<li>
<a hx-post="{{$postURL}}" hx-vals='{"content_action": {{$.ActionDeleteComment}}, "report_action": {{$.MarkAsHandled}}}' class="item">
{{svg "octicon-trash"}}{{ctx.Locale.Tr "moderation.action.comment.delete"}}
</a>
</li>
{{end}}
</ul>
</details>
{{end}}
</div>
</div>
</div>
</a>
</div>
{{end}}
</div>

View file

@ -16,6 +16,13 @@ document.body.addEventListener('htmx:sendError', (event) => {
// https://htmx.org/events/#htmx:responseError
document.body.addEventListener('htmx:responseError', (event) => {
// hide any previous flash message to avoid confusions (in case the
// error toast would have been shown over a success/info message)
const flashMsgDiv = document.getElementById('flash-message');
if (flashMsgDiv) {
flashMsgDiv.innerHTML = '';
flashMsgDiv.className = '';
}
// TODO: add translations
showErrorToast(`Error ${event.detail.xhr.status} when calling ${event.detail.requestConfig.path}`);
showErrorToast(`Error ${event.detail.xhr.status} when calling ${event.detail.requestConfig.path}: ${event.detail.xhr.responseText}`);
});