Skip to content

feat(moderation): let users mute from the profile and post menus - #70

Open
Adron wants to merge 1 commit into
mainfrom
feat/mute-user
Open

feat(moderation): let users mute from the profile and post menus#70
Adron wants to merge 1 commit into
mainfrom
feat/mute-user

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

APIClient.muteUser/unmuteUser already existed but were called from nowhere, and MutedUsersView could only unmute — so an iOS user could never add anyone to that list. This wires mute up everywhere Block already appears, and makes it visibly do something by filtering muted authors out of the feed.

Closes #47

What's included

Commit a1f57e9:

  • MuteStore (new) — a @MainActor singleton owning who this user has muted, seeded once from GET /api/user/mutes. A singleton rather than an @EnvironmentObject because the mute entry points sit behind sheets that would each have to re-inject it; observing the shared instance means a mute from any of them republishes into the feed with no manual refresh. Behind a narrow MuteAPI protocol (ISP), matching the existing DocumentSyncAPI seam, so it's testable without a network.
  • Optimistic with rollback — the author drops out of the feed on tap and comes back if the call fails, so a mute never looks applied when the server rejected it. Errors are rethrown rather than swallowed, so views route a feature-endpoint 401 through authState.handleUnauthorized() and never logout().
  • Profile menu (UserProfileView) — Mute @user / Unmute @user next to Block, state seeded from the mutes endpoint so it's correct on reopen.
  • Post menus — Mute in FeedView (via MessageRow), MessageDetailView and MessageThreadView. Detail and thread also offer Unmute; the feed deliberately cannot, because a muted author's rows are filtered out of it.
  • Feed filtering — muted authors are hidden from both the feed and search results alongside blocked ones, via a shared isVisibleAuthor predicate.
  • Shared confirmation — a muteConfirmation view modifier so the copy distinguishing mute from block is written once: "You won't see their posts in your feed. Unlike blocking, they can still follow you, message you, and see your posts."
  • MutedUsersView now reads and writes through the store, so it reflects mutes made from the new entry points.
  • Accessibility.accessibilityLabel on both new menu items (and on Block, which was missing one).

Testing

  • xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,id=027B74C4-…' buildBUILD SUCCEEDED, no new warnings in the touched files.
  • xcodebuild … -parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests test959 tests, 0 failures.
  • New MuteStoreTests (12 tests): seeding, seed-once, retry-after-failed-seed, optimistic rollback on mute and unmute, 401 rethrow on both, no-op paths, and that the confirmation copy actually names blocking.
  • APIClientModerationTests gains 5 mute/unmute request-shape cases: empty JSON object body, application/json content type, no body on DELETE, and percent-encoded user ids in both paths.
  • E2E suite not run (read-only, shared live account).

Notes / follow-ups

  • The issue's premise is slightly off: Block is only in FeedView's post menu today — MessageDetailView and MessageThreadView have Report-only menus. I added Mute to all three as asked, but did not add Block to the two that lack it, since that's outside this issue. Worth a follow-up if the three menus are meant to be identical.
  • UserProfileView derives targetUserId from the profile's first post, so on a profile with no posts the Mute item is present but inert. This is pre-existing and shared with Block and Report, which guard the same way — not introduced here, but it's a real papercut.

🤖 Generated with Claude Code

`APIClient.muteUser`/`unmuteUser` existed but were called from nowhere, and
`MutedUsersView` could only *un*mute — so an iOS user could never add anyone
to that list. Mute is now reachable everywhere Block is.

- Add `MuteStore`, a `@MainActor` singleton owning who this user has muted.
  A singleton rather than an `@EnvironmentObject` because the mute entry
  points sit behind sheets that would each have to re-inject it; observing
  the shared instance means a mute from any of them republishes into the
  feed with no manual refresh. Seeded once from `GET /api/user/mutes`.
- Mute/unmute is optimistic and rolls back on failure, so a mute never looks
  applied when the server rejected it. Errors are rethrown so views route a
  feature-endpoint 401 through `handleUnauthorized`, never `logout()`.
- Add Mute/Unmute to the profile `...` menu next to Block, and Mute to the
  post menus in `FeedView` (via `MessageRow`), `MessageDetailView` and
  `MessageThreadView`. Detail and thread also offer Unmute; the feed cannot,
  because a muted author's rows are filtered out of it.
- Filter muted authors out of the feed and search results alongside blocked
  ones, so muting from a post you're looking at visibly does something.
- Share the confirmation via a `muteConfirmation` modifier, so the copy
  telling mute apart from block ("Unlike blocking, they can still follow
  you, message you, and see your posts") is written once.
- Route `MutedUsersView` through the store so it reflects mutes made from
  the new entry points.
- Tests: `MuteStoreTests` covers seeding, optimistic rollback and 401
  rethrow; `APIClientModerationTests` gains mute/unmute request-shape cases
  (empty JSON object body, JSON content type, percent-encoded user id).

Closes #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ArbsSbbJsFfBofd1mWmjVy
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.

W2: Mute a user from the profile and post menus

1 participant