Skip to content

feat(safety): choose how muted authors' posts appear - #447

Open
dmnyc wants to merge 1 commit into
mainfrom
feat/muted-content-visibility
Open

dmnyc wants to merge 1 commit into
mainfrom
feat/muted-content-visibility

Conversation

@dmnyc

@dmnyc dmnyc commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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

Settings Hide completely
Safety settings — Muted content, three options Thread with muted posts hidden completely
Show placeholder Placeholder with reveal
Thread with muted posts collapsed to a counted placeholder row Muted placeholder rows with a Show link

The three modes

Mode Behavior
Hide completely Nothing renders. The thread closes over the gap, so a reply to a muted author loses its visible context — the deliberate trade for never seeing muted content.
Show placeholder A counted grey row keeps the reply chain intact. The previous behavior, and the default, so upgrading changes nothing.
Placeholder with reveal Adds Show / Hide links 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 ThreadView and PostCardView resolved 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. nestedReplyRow draws the connector rail in a ZStack around 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 in ThreadReplyFolder next 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.slash vs nosign) that the text after each icon started at a different x, which reads as a ragged left edge when they stack. They now share one hiddenContentRow builder 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 @State because thread rows sit in a LazyVStack: 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: onDisappear fires 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 UserDefaults representation) 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.swift sits in wisp/ rather than beside SafetyFilter.swift at the repo root: only wisp/ is a fileSystemSynchronizedGroup, so a new root file needs four hand-edits to project.pbxproj.

The same shim exists on the three Android apps and would need a port for parity.

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 barrydeen left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() sets blocked = true and returns before storing the fetched event (QuotedNoteView.swift:506 and :549 on this branch).
  • Tapping Show flips isRevealed → the if blocked, !isRevealed branch falls away → the body tries else if let eventevent is 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 ThreadView and PostCardView.
  • Filtering rows out of the tree before folding rather than emptying the view is correct — the ZStack connector rail would otherwise leave a dangling rail segment, a placeholder by another name.
  • mutedGroup mirrors the tested WoT path exactly, including the fold-anchor reachability guard; coupling groupMuted to 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; SafetyPreferences defaults to .placeholder so 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.

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.

2 participants