Conversation
Muting hid content but left a fixed presentation: a "Post from blocked
user" row wherever dropping the post outright would break the structure
around it. Two things people want from that are opposites — never laying
eyes on muted content at all, or peeking at one post to read or reply
without unmuting its author — and neither was possible.
Adds Safety → Muted content, a per-account choice of three:
- Hide completely: nothing renders. The thread closes over the gap.
- Show placeholder: a counted grey row. The previous behavior, and the
default, so upgrading changes nothing.
- Placeholder with reveal: adds Show / Hide links for one post at a
time.
Applies to thread rows (ancestor, focal, reply, nested) and quoted notes
embedded in other posts. The feed needed nothing — blocked authors never
reach it, so there is nothing there to place-hold.
Four things worth calling out:
Muted users were being NAMED in the "Replying to X" captions, in both
ThreadView and PostCardView, which leaked the identity the mute exists
to remove. They now always read "a muted user". That caption is also
the only breadcrumb left in hide-completely mode, so a reply to a
hidden post doesn't read as a non sequitur.
Hiding a row wasn't enough to hide it. `nestedReplyRow` draws the
connector rail in a ZStack *around* the row, so an emptied row still
painted a dangling rail — a placeholder by another name. Rows are
filtered out of the tree before folding instead.
Runs of muted rows at one depth collapse to a single counted row when
the mode offers no reveal: with nothing to do about an individual row,
N identical placeholders are pure noise. The reveal mode keeps them
separate, since each needs its own Show link. This lives in
ThreadReplyFolder next to the identical WoT grouping, so it's unit
tested rather than buried in a View.
The five "something is hidden here" rows — muted, WoT, both collapsed
variants, and the revealed-post strip — now share one `hiddenContentRow`
builder. They had drifted to three different font sizes, and SF Symbol
glyph widths differ enough (`eye.slash` vs `nosign`) that the text after
each icon started at a different x. The icon now sits in a fixed column.
Also settles the WoT copy, which disagreed with itself across the two
rows ("Hidden by Web of Trust filter" vs "Post hidden by WoT filter").
Reveals live in MutedRevealStore: in-memory, never persisted, dropped
when the thread goes away so reopening starts from hidden, and cleared
on account switch and data wipe. It's a store rather than per-row
@State because thread rows sit in a LazyVStack — scrolling a revealed
row out of view and back would silently re-hide it.
MutedRevealStore.swift sits in wisp/ rather than beside SafetyFilter.swift
at the root: only wisp/ is a fileSystemSynchronizedGroup, and a new root
file needs four hand-edits to project.pbxproj.
56 tests: mode semantics (including that reveal is never offered without
a placeholder, and that the raw values — the persisted representation —
stay stable), the store's lifecycle, and six grouping cases covering
per-depth grouping, an unmuted reply breaking a run, muted and WoT runs
staying distinct, and folded subtrees under a collapsed run staying
reachable.
barrydeen
left a comment
There was a problem hiding this comment.
Reviewed in full. The bulk of this is excellent — but the reveal feature has a functional bug on one of its two surfaces, so I've requested changes rather than approving.
Major: revealing a muted quoted note shows 'Quoted note not found'
- The blocked path in
load()setsblocked = trueand returns before storing the fetched event (QuotedNoteView.swift:506 and :549 on this branch). - Tapping Show flips
isRevealed→ theif blocked, !isRevealedbranch falls away → the body trieselse if let event→eventis nil → falls through to missingCard. - So the Show link on a muted quote card never reveals the note — and the resulting 'not found' card invites exactly the pointless retry this PR's own copy exists to avoid.
Patch idea: stash the event on the blocked path (self.event = resolved before the return — safe, since every non-revealed render path gates on blocked first), and when blocked && isRevealed, render the note with a 'Muted user / Hide' strip mirroring ThreadView's revealedBanner.
Related gap in the same surface: the revealed quote card has no Hide affordance and no muted-user banner — the Show/Hide claim holds only for thread rows, and a feed-surface reveal persists for the session with no way back while the post reads as ordinary content.
Merge hazard
This branch forked at d146ca8 (Sept 2), before #461/#462/#463 landed. Main's load() was refactored into present() by #461 — when this rebases, the event-stash must land in present()'s blocked branch, not the old shape. ThreadView and PostCardView also moved since (#466/#467), so expect conflicts there too.
The good — verified, not just taken on faith
- The identity-leak fix is right and deserves calling out: the 'Replying to X' captions naming muted authors predates this PR and is now fixed in both
ThreadViewandPostCardView. - Filtering rows out of the tree before folding rather than emptying the view is correct — the
ZStackconnector rail would otherwise leave a dangling rail segment, a placeholder by another name. mutedGroupmirrors the tested WoT path exactly, including the fold-anchor reachability guard; couplinggroupMutedto reveal-unavailability is the right rule.- Store hygiene is solid: in-memory only, thread-scoped
hide(), cleared on account switch (MainView.swift:298) and data wipe;SafetyPreferencesdefaults to.placeholderso upgrades change nothing; raw-value stability is pinned by tests.
The thread-row surfaces I checked (ancestor/focal/reply/nested, hide-mode filtering, grouping, reveal/Hide round-trip) are all correct — it's only the quoted-note reveal that needs the fix above.
Muting hid content but left a fixed presentation: a "Post from blocked user" row wherever dropping the post outright would break the structure around it. Two things people want from that are opposites — never laying eyes on muted content at all, or peeking at one post to read or reply without unmuting its author — and neither was possible.
Adds Safety → Muted content, a per-account choice of three.
Screenshots
The three modes
Show/Hidelinks to read or reply to one muted post without unmuting its author.Applies to thread rows (ancestor, focal, reply, nested) and quoted notes embedded in other posts. The feed needed nothing — blocked authors never reach it, so there is nothing there to place-hold.
Four things worth calling out
Muted users were being named. The "Replying to X" captions in both
ThreadViewandPostCardViewresolved and printed the display name of a muted p-tag, leaking the identity the mute exists to remove — in every mode, including before this change. They now always read "a muted user". That caption is also the only breadcrumb left in hide-completely mode, so a reply to a hidden post doesn't read as a non sequitur.Hiding a row wasn't enough to hide it.
nestedReplyRowdraws the connector rail in aZStackaround the row, so an emptied row still painted a dangling rail segment — a placeholder by another name. Rows are filtered out of the tree before folding instead, so the thread genuinely closes over the gap.Runs of muted rows collapse when there's nothing to do with them. With no per-post reveal, N identical unactionable placeholders are pure noise, so a same-depth run becomes one counted row. The reveal mode keeps them separate — each needs its own
Show. This lives inThreadReplyFoldernext to the identical WoT grouping, so it's unit tested rather than buried in a View.The hidden-content rows had drifted apart. The five of them — muted, WoT, both collapsed variants, and the revealed-post strip — were at three different font sizes, and SF Symbol glyph widths differ enough (
eye.slashvsnosign) that the text after each icon started at a different x, which reads as a ragged left edge when they stack. They now share onehiddenContentRowbuilder with the icon in a fixed column. This also settles the WoT copy, which disagreed with itself across the two rows ("Hidden by Web of Trust filter" vs "Post hidden by WoT filter") — both now spell it out, matching the settings screen.Reveals
MutedRevealStore— in-memory, never persisted. Dropped when the thread goes away, so reopening starts from hidden; cleared on account switch and data wipe, so one identity's reveals never carry into another's session.It's a store rather than per-row
@Statebecause thread rows sit in aLazyVStack: scrolling a revealed row out of view and back tears down its state, which would silently re-hide the post the user just opened.One tradeoff to be aware of:
onDisappearfires both when a thread is popped and when it's covered by a push, and SwiftUI offers no way to tell them apart. So revealing a post and then tapping into its own sub-thread re-hides it on the way back. That's the safe direction for a filter the user opted into, and re-revealing is one tap.Testing
56 tests. Mode semantics — including that reveal is never offered without a placeholder, and that the raw values (the persisted
UserDefaultsrepresentation) stay stable, since changing one would silently reset an account's choice. The store's lifecycle, including that a thread's scoped clear leaves a reveal on another surface intact. And six grouping cases: per-depth grouping, an unmuted reply breaking a run, muted and WoT runs staying distinct, and folded subtrees under a collapsed run staying reachable.Built and installed on the simulator.
Notes
MutedRevealStore.swiftsits inwisp/rather than besideSafetyFilter.swiftat the repo root: onlywisp/is afileSystemSynchronizedGroup, so a new root file needs four hand-edits toproject.pbxproj.The same shim exists on the three Android apps and would need a port for parity.