feat(mute): auto-expiring timed mutes (LC-535) #509

Merged
longjacksonle merged 4 commits from feat/LC-535-timed-mutes into main 2026-07-04 23:34:28 +02:00

What

Timed mutes (LC-535). The global posting mute can now carry an expiry, so a moderator can mute someone for 15m / 1h / 8h / 24h / 7d instead of only permanently. The mute lifts itself when the clock passes it - no follow-up unmute needed.

This wires up the long-dormant users.muted_until column, which existed but was never set (post_mute always passed None) and never enforced.

How

  • Core (0189ef3). User::mute_in_effect() decides at the posting gate whether a mute is still in force: a NULL muted_until is permanent, a past timestamp has lifted, an unparseable one fails safe to still-muted. db::auth::clear_expired_mutes nulls expired rows so the admin table and exports stay truthful, and spawn_mute_expiry_scanner runs that sweep every 60s (modeled on the custom-status expiry scanner).
  • Enforce (4e0c9d6). All seven "banned or muted cannot post" checks (message send, DM send, forward, gif, poll create/vote, api post) go through mute_in_effect() instead of the raw is_muted flag, so a timed mute stops blocking the moment it expires rather than only after the sweep nulls the row.
  • Admin UI (d6492bb). The single Mute button becomes a duration <select> (15m / 1h / 8h / 24h / 7d / permanent); post_mute resolves the preset to a muted_until expiry, records it in the mod-log detail and the UserMuted broadcast, and the row shows "Muted until <ts> UTC" for a timed mute. An unrecognised preset 400s rather than silently muting forever.
  • Test (f71dee5). clear_expired_mutes integration coverage: a past mute is nulled and counted; permanent and future mutes are left in force with zero cleared. The expiry decision (mute_active) also has lib unit tests that run in CI.

Notes

  • No migration: muted_until already exists.
  • Permanent mute behaviour is unchanged (NULL expiry, never swept).
  • Bans (banned_until) are deliberately untouched - this ticket is scoped to mutes.

🤖 Generated with Claude Code

https://claude.ai/code/session_015RTKivuxdzFXdDyHZmsgYe

## What Timed mutes (LC-535). The global posting mute can now carry an expiry, so a moderator can mute someone for 15m / 1h / 8h / 24h / 7d instead of only permanently. The mute lifts itself when the clock passes it - no follow-up unmute needed. This wires up the long-dormant `users.muted_until` column, which existed but was never set (`post_mute` always passed `None`) and never enforced. ## How - **Core (`0189ef3`).** `User::mute_in_effect()` decides at the posting gate whether a mute is still in force: a NULL `muted_until` is permanent, a past timestamp has lifted, an unparseable one fails safe to still-muted. `db::auth::clear_expired_mutes` nulls expired rows so the admin table and exports stay truthful, and `spawn_mute_expiry_scanner` runs that sweep every 60s (modeled on the custom-status expiry scanner). - **Enforce (`4e0c9d6`).** All seven "banned or muted cannot post" checks (message send, DM send, forward, gif, poll create/vote, api post) go through `mute_in_effect()` instead of the raw `is_muted` flag, so a timed mute stops blocking the moment it expires rather than only after the sweep nulls the row. - **Admin UI (`d6492bb`).** The single Mute button becomes a duration `<select>` (15m / 1h / 8h / 24h / 7d / permanent); `post_mute` resolves the preset to a `muted_until` expiry, records it in the mod-log detail and the `UserMuted` broadcast, and the row shows "Muted until &lt;ts&gt; UTC" for a timed mute. An unrecognised preset 400s rather than silently muting forever. - **Test (`f71dee5`).** `clear_expired_mutes` integration coverage: a past mute is nulled and counted; permanent and future mutes are left in force with zero cleared. The expiry decision (`mute_active`) also has lib unit tests that run in CI. ## Notes - No migration: `muted_until` already exists. - Permanent mute behaviour is unchanged (NULL expiry, never swept). - Bans (`banned_until`) are deliberately untouched - this ticket is scoped to mutes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015RTKivuxdzFXdDyHZmsgYe
Wire up the long-dormant users.muted_until column so a mute can carry an expiry. Three pieces, no enforcement change yet: User::mute_in_effect() decides at the posting gate whether a mute is still in force (a NULL muted_until is permanent, a past timestamp has lifted, an unparseable one fails safe to still-muted), db::auth::clear_expired_mutes nulls expired rows so the admin table and exports stay truthful, and spawn_mute_expiry_scanner runs that sweep every 60s (modeled on the custom-status expiry scanner).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RTKivuxdzFXdDyHZmsgYe
Route all seven "banned or muted cannot post" checks (message send, DM send, forward, gif, poll create/vote, api post) through User::mute_in_effect() instead of the raw is_muted flag, so a timed mute stops blocking the moment it expires rather than only after the 60s sweep nulls the row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RTKivuxdzFXdDyHZmsgYe
Replace the single "Mute" button with a duration <select> (15m / 1h / 8h / 24h / 7d / permanent); post_mute resolves the preset to a muted_until expiry, records it in the mod-log detail and the UserMuted broadcast, and the row now shows "Muted until <ts> UTC" for a timed mute. An unrecognised preset 400s rather than silently muting forever.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RTKivuxdzFXdDyHZmsgYe
test(mute): expiry sweep lifts past mutes, spares permanent and future (LC-535)
All checks were successful
check-secrets / Kingfisher (push) Successful in 6s
check-secrets / TruffleHog (push) Successful in 6s
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 6s
check-secrets / Nosey parker (pull_request) Successful in 7s
check-secrets / TruffleHog (pull_request) Successful in 7s
Check / clippy + fmt + tests (pull_request) Successful in 3m59s
Create release / Create release from merged PR (pull_request) Has been skipped
f71dee5423
Cover clear_expired_mutes: a mute whose muted_until is in the past is nulled (flag, expiry, and reason all cleared) and counted, while a permanent (NULL) mute and a still-future mute are both left in force and the sweep reports zero cleared.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RTKivuxdzFXdDyHZmsgYe
longjacksonle deleted branch feat/LC-535-timed-mutes 2026-07-04 23:34:28 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!509
No description provided.