feat(moderation): let users mute from the profile and post menus - #70
Open
Adron wants to merge 1 commit into
Open
feat(moderation): let users mute from the profile and post menus#70Adron wants to merge 1 commit into
Adron wants to merge 1 commit into
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
APIClient.muteUser/unmuteUseralready existed but were called from nowhere, andMutedUsersViewcould 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@MainActorsingleton owning who this user has muted, seeded once fromGET /api/user/mutes. A singleton rather than an@EnvironmentObjectbecause 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 narrowMuteAPIprotocol (ISP), matching the existingDocumentSyncAPIseam, so it's testable without a network.authState.handleUnauthorized()and neverlogout().UserProfileView) — Mute @user / Unmute @user next to Block, state seeded from the mutes endpoint so it's correct on reopen.FeedView(viaMessageRow),MessageDetailViewandMessageThreadView. Detail and thread also offer Unmute; the feed deliberately cannot, because a muted author's rows are filtered out of it.isVisibleAuthorpredicate.muteConfirmationview 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."MutedUsersViewnow reads and writes through the store, so it reflects mutes made from the new entry points..accessibilityLabelon both new menu items (and on Block, which was missing one).Testing
xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,id=027B74C4-…' build— BUILD SUCCEEDED, no new warnings in the touched files.xcodebuild … -parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests test— 959 tests, 0 failures.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.APIClientModerationTestsgains 5 mute/unmute request-shape cases: empty JSON object body,application/jsoncontent type, no body on DELETE, and percent-encoded user ids in both paths.Notes / follow-ups
FeedView's post menu today —MessageDetailViewandMessageThreadViewhave 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.UserProfileViewderivestargetUserIdfrom 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