v4.23 — Guideline 1.2 User Blocking
Block is now a first-class safety primitive: discoverable beside Report on every UGC surface, symmetric across the whole app, and reversible from Settings.
Summary
Objectuve already satisfied App Store Review Guideline 1.2's reporting half — a user could report offensive content. It did not satisfy the blocking half. Mutations::BlockAlly worked end to end, but it was reachable only through the ally-search flow, filtered no content on any read path, and had no visible or reversible UI. A reviewer following the natural path — see abusive content, act on it — would have hit Report, then a dead end where Block should have been.
This milestone was filed pre-emptively, while auditing moderation surfaces for the 4.3.0 App Store review notes (following the 4.2.0 rejection on unrelated guidelines 2.1(a)/3.1.1), rather than waiting for a reviewer to find the gap. Four phases closed it: backend enforcement of symmetric content filtering across every read path; a shared Block affordance beside Report on the four surfaces where abuse actually happens; a Settings screen to see and undo a block; and the copy and docs to describe all of it accurately. No database migration was needed — the existing UserAlly schema (status: 'blocked') already carried enough information for a symmetric, both-directions filter.
Goal
Make block a first-class safety primitive, on par with report — closing App Store Review Guideline 1.2 (Safety — UGC), which expects a UGC app to offer both "a mechanism to report offensive content" and "the ability to block abusive users." A user who can Report abusive content anywhere can also Block there; a block actually hides the blocked user's content; and a block is visible and reversible from Settings.
Scope — What Shipped
- Symmetric content filtering (backend).
UserAlly.blocked_user_ids_forcomputes a single both-directions "blocked with me" set per request, shared via aBlockingHelpersGraphQL concern and applied at every community, feed, goal, and notification read path — includingunified_feed's notification-actor exclusion, which the original filter missed and Roy caught in review. blockedUsersquery +unblockUsermutation. A caller can list who they've blocked (from either direction) and reverse a block —Social::UnblockAllyhard-deletes theUserAllyrow rather than restoring prior ally state, since the no-migration symmetric model has no prior state to restore.searchUsersnormalized to symmetric. Previously only excluded users who blocked you; now also excludes users you blocked.- Block beside Report on four point-of-abuse surfaces. A shared
BlockConfirmSheetcomponent, reused on a community post, a post comment, a goal-event comment, and a public profile — the same four surfacesReportContentModal.vuealready covers. - Settings → Privacy → Blocked. A
/settings/blockedscreen listing everyone the caller has blocked, with a one-tap Unblock that confirms, mutates, evicts the cache, and refetches — no stale rows. - Docs and copy. A new
docs/features/blocking-and-safety.md, corrected stale "one-way, non-reversible" claims in the ally docs, a real site-map entry, a user-guide section, and aCHANGELOG.mdentry.
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | Backend — enforce blocking | Shipped | 1 | Symmetric content filtering across every read path + blockedUsers/unblockUser + symmetric searchUsers. No schema migration. PR #1873. |
| 2 | Block at the point of abuse | Shipped | 1 | Desi's single 5-surface UI-SPEC; shared BlockConfirmSheet wired to all 4 abuse surfaces; bundled a load-bearing public_id resolver fix. PR #1883. |
| 3 | Blocked-users management screen | Shipped | 1 | Settings → Privacy → /settings/blocked, all 6 UI-SPEC states covered. PR #1884. |
| 4 | Copy & docs | Shipped | 1 | docs/features/blocking-and-safety.md, corrected ally docs, CHANGELOG, review-notes one-liner. PR #1886. |
Key Decisions
- Symmetric, not directional, content filtering — a block hides content in both directions, computed as a union over the existing
UserAllytable. This avoided ablocked_by_id/UserBlockmigration and backfill entirely, at the cost of not being able to distinguish "A blocked B" from "B blocked A." Locked by Josh before kickoff as a one-way door, since reversing it once content-filtering ships would be disruptive. - Reuse
Social::BlockAlly, add an unblock path — no parallel block model. Unblock hard-deletes theUserAllyblock row rather than restoring a prioracceptedally relationship; under the no-migration symmetric model there is no stored prior state to restore. Product-accepted, documented as intended semantics. - One UI-SPEC for all 5 surfaces. The four point-of-abuse affordances and the Settings blocked-users list share confirm copy and interaction language, so Desi produced a single spec serving both Phase 2 and Phase 3, letting the two phases run in parallel once Phase 1 landed.
- No feature flag. This is a safety-completeness close with a clean per-phase revert as rollback, not a risky new surface — unlike several recent milestones that shipped behind a flag.
Requirements Coverage
This milestone has no standalone REQUIREMENTS.md — coverage is derived from the ROADMAP's own "three gaps this milestone closes" framing, per this repo's planning canon for hand-authored milestones.
| Gap closed | Status |
|---|---|
| Not discoverable at the point of abuse | ✅ Satisfied — Block beside Report on all 4 surfaces |
| Blocking hides nothing | ✅ Satisfied — symmetric filtering across every content read path |
| No way to see or undo a block | ✅ Satisfied — Settings → Privacy → Blocked, with Unblock |
Outcomes
A user who reports abusive content can now also block that user, from the same surface, with the same confidence: the block actually hides the other person's posts, comments, and profile everywhere in the app, in both directions, and it can be undone in one tap from Settings at any time. The App Store Guideline 1.2 gap identified ahead of the 4.3.0 review is closed.
Tech Debt
- (Phase 1)
spec/interactions/social/build_unified_feed_spec.rb's blocked-user exclusion example maps over the wrong hash key and passes vacuously — a test-integrity gap, not a functional regression (the underlying filter was independently verified correct). One-line fix, unscheduled. - (follow-up candidate, Josh's triage) Report is wired on community posts only (1 of 4 surfaces), while Block now covers all 4 — Report/Block surface parity is the next most valuable safety increment, not in this milestone's scope.
- (follow-up candidate, Josh's triage)
blockedUsersrow-meta omits "· blocked {relative time}" since Phase 1 never surfaced ablocked_attimestamp — correctly omitted rather than fabricated. - (Phase 2, four non-blocking nits) Toast auto-dismiss timing, a silently-failing Undo tap, two Storybook variants that don't depict the states they claim to, and ad-libbed unblock-toast copy not in the microcopy table — none gating.
Related Artifacts
- Roadmap: v4.23-guideline-1-2-user-blocking-ROADMAP.md
- Milestone Audit: v4.23-guideline-1-2-user-blocking-MILESTONE-AUDIT.md
- Feature docs: docs/features/blocking-and-safety.md
- UI-SPEC: .planning/phases/v4.23-phase-2-block-at-point-of-abuse/UI-SPEC.md
- Merge PRs: #1873, #1883, #1884, #1886
Related Commits
5e24fe62d— Phase 1: backend symmetric block-content filteringbc9ba3cc7— Phase 2: Block at the point of abuse5fb8f4750— Phase 3: blocked-users management screen42c57273b— Phase 4: copy & docs
Last updated: 2026-07-30