Skip to content

feat: rejection reason templates, review claims and a decision log for moderation - #172

Closed
xHeaven wants to merge 43 commits into
dev-null-sh:productionfrom
xHeaven:feat/moderation-helpers
Closed

xHeaven wants to merge 43 commits into
dev-null-sh:productionfrom
xHeaven:feat/moderation-helpers

Conversation

@xHeaven

@xHeaven xHeaven commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Problem

The admin moderation queue had approve/reject and a free-text reason, nothing else:

  • Admins retyped the same rejection reasons every time.
  • Two admins could review and act on the same commit at once without knowing it.
  • Nothing recorded which admin approved or rejected a commit, or when.

What changes

Rejection reason templates. A new Reasons dialog in the queue header lets admins create, edit and delete shared templates (title of up to 40 characters, body of up to 280). Under each commit's actions, the templates show as a single row of chips that scrolls sideways when it doesn't fit. Clicking a chip, or pressing 1–9 on the focused commit, prefills the reason, which stays editable. The digits work like the existing a/x shortcuts: they are buttons with aria-keyshortcuts inside the focused commit. ⌘↵ in the reason field rejects. The keyboard shortcuts dialog lists both.

Claims. Focusing or acting on a commit claims it for 5 minutes (PUT moderation/{commit}/claim, debounced). Each admin holds one claim at a time, so moving to the next commit releases the previous one. Claim behaviour:

  • Other admins see "@mara is reviewing this · 4m left" with a Take over button, in place of the approve and reject buttons. Selecting the commit and the reason chips stay disabled for them until the claim expires or they take it over.
  • Your own claim shows your avatar and the time left at the end of the action row.
  • Claims refresh every 15 s with a partial reload of only claims.
  • The server also enforces claims. The approve, reject and bulk-approve requests fail with "@mara is reviewing this commit." when another admin holds an active claim. The queue then shows that error and the current claim, instead of silently resetting.
  • Decided commits release their claim.

Decision log. UpdateCommitStatuses now takes the acting admin and writes a commit_decisions row (admin, status, reason, time) for every commit it changes. Admins see the log in three places:

  • Decisions (/moderation/decisions): newest first, with infinite scroll.
  • The commit's page: a small "Moderation (only admins see this)" block.
  • The queue header: a link to Decisions.

Polish

A pass over this PR's UI using the standards from #156 (no layout jumps, no reload flashes, skeletons sized like the content, no clipping). The measurements were taken in Chrome with a PerformanceObserver for layout shifts. "Largest jump" is the biggest distance any row moved in a single frame.

Interaction Before: largest jump After: largest jump
Focusing a commit (claim, reason chips) 74 px 0
Another admin's claim arriving by polling 50 px 0
Taking over 78 px 0
Picking a template 144 px 25 px, as a 300 ms reveal
Approving or rejecting 74 px, instant 28–52 px per frame, as a 300 ms collapse

Review queue

  • The claim line no longer adds or removes a row.
    • Another admin's claim now covers the action row it locks, so nothing below it moves.
    • Your own claim (your avatar and "4m left") sits in space the action row always reserves.
    • The countdown uses tabular figures.
  • The reason chips are always there. Before, they appeared only while the commit was focused, so j/k grew and shrank every row. They are now one muted row of chips that scrolls sideways and snaps to whole chips. A chip picked by keyboard scrolls into view if it was cut off.
  • The reason box slides open instead of appearing at once. Picking a template only fills it, at a fixed height. Long templates scroll inside it. Add reason stays in place as a toggle; closing it clears the reason.
  • Approved and rejected commits collapse away (300 ms, the same motion as deleting a commit), and scroll stays put. If the server refuses, for example because of a claim, the row reopens with the error.
    • Only that row is disabled while it leaves. Before, every row in the queue dimmed for the length of the request.
    • Focus moves to the next commit.
    • Bulk approve collapses the selected commits the same way, and its button no longer changes to "Approving…" or resizes while it runs.
  • Take over keeps focus on the commit's Approve button. Before, focus fell back to the page.
  • No remount after moderating. Queue visits preserve state, so a reason typed on another commit survives approving this one.
  • Long reason titles truncate inside their chip. Reason fields use 16px text on phones so iOS doesn't zoom.

Dialogs

  • The Reasons dialog has a fixed header and footer, and only the list between them scrolls. Before, the whole dialog scrolled: the title and close button scrolled away, and the add form opened below the fold.
    • The dialog is anchored near the top, so adding, editing or deleting a reason grows it downward instead of re-centring it.
    • Cancel and Add reason live in the footer.
    • New and edited reasons scroll into view.
    • The list doesn't scroll the page behind it.
    • Reasons past the ninth keep their titles aligned with the numbered ones.
  • All dialogs are capped to the viewport and scroll. This uses the same line as fix: remove layout jumps, flashes and clipped ui across the app #156, so both PRs merge cleanly. It matters here because this PR adds rows to the keyboard shortcuts dialog.
  • Empty Reasons dialog: $ ls reasons/ and "No reasons yet. Add the ones you keep typing out."

Decision log

  • Long usernames wrap instead of overflowing.
  • Loading more shows skeleton rows that match a decision row (74 px), instead of a spinner.
  • Moderation in the nav stays highlighted on Decisions.
  • The back arrow gets a hover and focus target.

Before and after: reviewing with the keyboard (j, j, 3, ⌘↵, a)

Before After
Rows jump when focusing, picking a reason and moderating Rows stay still; moderated rows collapse

MP4: before · after

Before and after: two admins, @jonas (left) and @mara (right)

@mara claims a commit. @jonas's next poll shows the claim, and @jonas takes it over. @mara's stale approve is refused. @jonas then rejects with reason 1.

Before After
Claim line pushes rows down; take over drops focus Claim overlays the action row; nothing moves

MP4: before · after

Before and after: the Reasons dialog on a phone (12 reasons)

Before After
Title scrolls away; add form opens below the fold Header and footer stay; only the list scrolls

MP4: before · after

Implementation

  • Models and migrations: RejectionReason, CommitClaim (unique commit_id and unique user_id, plus expires_at), and CommitDecision (the admin is nulled if their account is deleted; the row is deleted with the commit).
  • CRUD-only names:
    • RejectionReasonController@store/update/destroy → Create/Update/DeleteRejectionReason[Request]
    • CommitClaimController@update → UpdateCommitClaimRequest → UpdateCommitClaim
    • CommitDecisionController@index → ListCommitDecisionsRequest + CommitDecisionsQuery
  • All checks live in form requests. A new App\Rules\Unclaimed rule backs the approval, rejection, bulk-approval and claim requests (the claim request skips it when take_over is true). Only pending commits can be claimed.
  • UpdateCommitStatus(es)::handle() now takes User $moderator first, and every caller passes the current admin.
  • Queue visits use preserveState: true and also reload claims.
  • CommitFeed takes removingIds, and CommitCard takes isRemoving, to collapse rows that are leaving.

Overlap check

This doesn't reimplement #93 (reports), #104 (risk scoring), #125 (Jev rules) or #142 (trusted authors). #104 and #125 also edit moderation/show.tsx and ModerationController, and #125 edits UpdateCommitStatuses and adds types/moderation.ts. Expect textual conflicts with whichever lands first. Jev's automated decisions in #125 would need to pass a moderator, or make it nullable, once both are merged.

The polish touches commit-card.tsx, commit-feed.tsx and app-nav.tsx, which #156 also edits; expect small rebases there.

Media

Captured against a local SQLite database with seeded admins @mara and @jonas, twelve rejection reasons (some long), and a 37-character GitHub username.

Picking a reason from the keyboard (j, j, 3, edit, ⌘↵)

Picking a rejection reason with the keyboard

MP4

Approving three selected commits

Bulk approve collapses the selected commits

MP4

Managing reasons (add, edit, delete)

Managing rejection reasons

MP4

Decision log: approve, open Decisions, load more, open the commit

Decision log

MP4

Reason picked on the focused commit

Desktop light Desktop dark
Picker light Picker dark
Mobile light Mobile dark
Picker mobile light Picker mobile dark

Commit claimed by another admin

Desktop light Desktop dark
Claimed light Claimed dark
Mobile light Mobile dark
Claimed mobile light Claimed mobile dark

Reasons dialog

Desktop light Desktop dark
Dialog light Dialog dark
Mobile light Mobile dark Adding, mobile light Adding, mobile dark
Dialog mobile light Dialog mobile dark Adding mobile light Adding mobile dark

Decisions page

Desktop light Desktop dark
Decisions light Decisions dark
Mobile light Mobile dark
Decisions mobile light Decisions mobile dark

Decisions on a commit's page (admins only)

Desktop light Desktop dark
Commit light Commit dark
Mobile light Mobile dark
Commit mobile light Commit mobile dark

Empty states: queue, reasons, decisions

Desktop light Mobile dark
Empty queue Empty queue mobile
Empty reasons Empty reasons mobile
Empty decisions Empty decisions mobile

Validation

Feature:

  • Tests were written first, for the reasons CRUD, claims (claim, release the previous claim, conflict, take over, expired, pending only, non-admin), claim enforcement on approve, reject and bulk approve, decisions recorded with the moderator and reason, the decisions page (including deleted commits), and decisions on the commit page for admins only.
  • pest --coverage --exactly=100.0: 987 passed, 100.0% coverage (run with Xdebug).
  • pest --type-coverage --min=100: 100%.
  • phpstan, rector --dry-run and pint --test pass. bun run test:lint and bun run test:types pass.

Polish (front end only, no behaviour change on the server, so no new tests):

  • pest on the moderation, claim, reason, decision, approval, rejection and commit controller tests plus ArchTest: 137 passed.
  • pest --type-coverage --min=100: 100%.
  • phpstan, rector --dry-run, pint --test, bun run test:lint, bun run test:types and bun run build pass.
  • Layout shift, per-frame row movement, focus after each action, dialog bounds and scroll containers were measured in Chrome with two admin sessions, at 1280×800, 1280×650, 390×844 and 390×600, against the same seeded data before and after.
  • Every screenshot reports 0 horizontal overflow and no console errors.

Limitations

  • Claims update for other admins by polling every 15 s, not by push, so for up to 15 s they can see a stale "available" state. The server-side claim check covers that gap.
  • A claim is not renewed while an admin sits idle on a commit. It lapses after 5 minutes unless they interact with the commit again.
  • Decisions made before this change have no log entries.
  • Approving or rejecting now waits for the 300 ms collapse before sending the request (none with reduced motion).
  • Deleting a reason has no confirmation, as before.
  • A failed bulk approve restores the rows but shows no message, as before.
  • Moving to Decisions or a commit page still flashes the shared "Who to follow" skeleton in the aside. fix: remove layout jumps, flashes and clipped ui across the app #156 fixes that app-wide, so it isn't repeated here.

@xHeaven

xHeaven commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Pretty much superseded by AI moderation.

@xHeaven xHeaven closed this Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants