feat(threads): per-thread mute (LC-546) #515

Merged
longjacksonle merged 4 commits from feat/LC-546-per-thread-mute into main 2026-07-07 04:18:28 +02:00

What

Per-thread mute (LC-546), the first Round-4 quick win. Lets a member silence one noisy thread's reply notifications without unfollowing every thread they have participated in. It is the exact inverse of LC-310 thread following.

How

  • Data layer - new thread_muters table (chat/0084), a standalone subscription keyed by the thread root message, mirroring thread_followers (schema, index, ON DELETE CASCADE). db module exposes idempotent mute/unmute, is_muted, and a bulk muters() for the fan-out.
  • Enforcement - notify_thread_followers drops any follower present in the thread's muter set. Replying auto-follows you (LC-310), so filtering at fan-out time (rather than deleting the follow) lets mute win over an existing auto-follow with a single bulk lookup + HashSet check.
  • UI - POST/DELETE /room/{id}/thread/{parent}/mute (same access + parent gate as follow), a ThreadMuteFragment button that swaps itself outerHTML, and the mute state surfaced on the thread panel so the toggle renders correctly on open. The button sits beside the follow toggle in the panel header. en/es strings added (i18n parity test green).

Tests

  • db_thread_muters - round-trip, idempotency, FK cascade on root delete.
  • routes_thread_mute - endpoints flip state and swap the button (hx-post <-> hx-delete); a muter stays auto-followed yet muted across a later reply (the fan-out filter's precondition); anonymous mute redirects to login with no row written.

Green locally on just check, full just test, and just test-saas.

🤖 Generated with Claude Code

https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw

## What Per-thread mute (LC-546), the first Round-4 quick win. Lets a member silence one noisy thread's reply notifications without unfollowing every thread they have participated in. It is the exact inverse of LC-310 thread following. ## How - **Data layer** - new `thread_muters` table (`chat/0084`), a standalone subscription keyed by the thread root message, mirroring `thread_followers` (schema, index, `ON DELETE CASCADE`). db module exposes idempotent `mute`/`unmute`, `is_muted`, and a bulk `muters()` for the fan-out. - **Enforcement** - `notify_thread_followers` drops any follower present in the thread's muter set. Replying auto-follows you (LC-310), so filtering at fan-out time (rather than deleting the follow) lets mute win over an existing auto-follow with a single bulk lookup + `HashSet` check. - **UI** - `POST`/`DELETE /room/{id}/thread/{parent}/mute` (same access + parent gate as follow), a `ThreadMuteFragment` button that swaps itself `outerHTML`, and the mute state surfaced on the thread panel so the toggle renders correctly on open. The button sits beside the follow toggle in the panel header. en/es strings added (i18n parity test green). ## Tests - `db_thread_muters` - round-trip, idempotency, FK cascade on root delete. - `routes_thread_mute` - endpoints flip state and swap the button (`hx-post` <-> `hx-delete`); a muter stays auto-followed yet muted across a later reply (the fan-out filter's precondition); anonymous mute redirects to login with no row written. Green locally on `just check`, full `just test`, and `just test-saas`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
Per-thread mute is the inverse of LC-310 thread following: a standalone subscription table keyed by the thread root message, so a member can silence one noisy thread's reply notifications without unfollowing every thread they have replied in. Mirrors thread_followers (schema, index, idempotent mute/unmute, is_muted, and a muters() bulk lookup for filtering the fan-out in a single query). Enforcement lands in the fan-out in a follow-up commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
notify_thread_followers now drops any follower who has muted the thread. Replying auto-follows the replier (LC-310), so mute is the only way to opt back out of a thread you have participated in; enforcing it here (rather than at insert time) means an existing auto-follow is honored without a delete. Single bulk muters() lookup, HashSet membership check in the existing follower loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
Adds POST/DELETE /room/{id}/thread/{parent}/mute (same access + parent gate as follow), a ThreadMuteFragment button that swaps itself outerHTML, and the mute state (is_muted) on the thread panel so the toggle renders correct on open. Button sits beside the follow toggle in the panel header. en/es strings for mute/muted labels and titles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
test(threads): db + route coverage for per-thread mute (LC-546)
All checks were successful
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Successful in 3m23s
Create release / Create release from merged PR (pull_request) Has been skipped
48c1000053
db_thread_muters: mute/unmute/is_muted/muters round-trip, idempotency, and FK cascade when the thread root is deleted. routes_thread_mute: the endpoints flip state and swap the toggle button (hx-post <-> hx-delete), a muter stays auto-followed yet muted across a later reply (the fan-out filter's precondition), and an anonymous mute is redirected to login with no row written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-07 04:17:10 +02:00
longjacksonle deleted branch feat/LC-546-per-thread-mute 2026-07-07 04:18: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!515
No description provided.