Soft moderation (Phase 5): - Blur cards that ≥2 hop-1 follows have muted (kind-10000) or reported (kind-1984) - Overlay shows count + dominant reason; "Show anyway" reveals for the session - Settings toggle under Community (on by default, hidden when logged out) - Trust beats crowd: self/trusted authors never blurred - Spec doc at docs/WOT_SOFT_MODERATION_FEATURE.md Feed scrolling: - Removed nested scroll container on .feedWall (max-height + overflow-y caused the wall to only show ~2 rows because the outer .modalCard ate scroll events) - Re-targeted infinite-scroll listener to .modalCard - Added top-up loop in appendFeedRows so the first chunk fills the viewport Template d-tag collisions: - Cover/disc/jewel/disc-design publishes now prefix the d tag with the mode (e.g. cover:tt12345 vs disc:tt12345) so a user can publish multiple template types for the same IMDB without one replacing the other at the relay level - buildRowsFromEvents and deduplicateRows now key by (address, mode) so legacy events with unprefixed d-tags also coexist correctly in the UI Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
12 KiB
Web-of-Trust Soft Moderation (Blur-by-WoT)
Goals
Use the signals already produced by Phase 2 (NIP-51 mute lists) and Phase 4 (NIP-56 reports) to passively dim community-feed cards that people the viewer trusts have flagged — without centralizing moderation or hard-hiding content.
Product goals
- Reduce the visual surface area of suspected spam / abuse for logged-in users.
- Use only Nostr-native data already being published.
- Stay non-censorial: every soft-moderated card is one click away from full view.
- Cost no more than ~1 extra relay round-trip per feed load.
Non-goals (v1)
- Replacing the personal block list. Soft moderation is additive to personal blocks.
- Cross-relay consensus or scoring servers.
- Soft moderation for logged-out users (no WoT to weight against).
- Hiding the author entirely (that is what Phase 1 block is for).
- Punishing the reporter (no "reporter reputation" yet).
Problem statement
Phase 4 added the ability for any user to publish kind-1984 reports, but those reports currently have no consumer in the UI. The same is true of mute lists from people the viewer follows — Phase 2 syncs the viewer's own mute list but doesn't read anyone else's.
That means abuse signal already flowing through the relays is being thrown on the floor. Soft moderation closes that loop by turning the signal into a visual cue the viewer can act on.
Data sources
| Signal | Kind | Author scope | Weight (v1) |
|---|---|---|---|
| Mute by a hop-1 follow | 10000 |
viewer's hop-1 set | 1 |
| Report by a hop-1 follow | 1984 |
viewer's hop-1 set | 1 (any reason) |
| Mute by a hop-2 follow | 10000 |
viewer's hop-2 set | 0 (ignored in v1) |
| Mute/report by stranger | either | anyone else | 0 (ignored) |
Reports use the reason tag (spam, nudity, illegal, impersonation, other) — surfaced in the overlay label so the viewer sees why, not just that, the card was flagged.
Why hop-1 only in v1
Hop-2 introduces too much noise and a real mob-blur risk: three coordinated strangers two hops away can dim a creator for thousands of people. Hop-1 means someone the viewer personally trusts has made a moderation decision. That's a much stronger signal and a much harder graph to game.
Hop-2 can be added later behind a setting if hop-1 alone proves too sparse.
Threshold model (v1)
A card is soft-moderated when:
hop1_mutes(author) + hop1_reports(author OR event) >= SOFT_BLUR_THRESHOLD
Where:
hop1_mutes= number of distinct hop-1 follows whose latest kind-10000 includes this author's pubkey in aptag.hop1_reports= number of distinct hop-1 follows who have published a kind-1984 against this author or this specific event.SOFT_BLUR_THRESHOLD = 2(constant for v1, tunable later).
Rationale for 2:
- 1 is too aggressive — a single grudge can dim a creator.
- 3+ is too rare in practice for most users' follow graphs (who may follow 50–500 people, of whom only a handful are active moderators).
- 2 means "at least two people I trust independently flagged this," which is the lowest threshold that demonstrably isn't a one-person decision.
What is NOT moderated
- Authors the viewer themselves follows (
row.trust === 'trusted'or'self') — never blurred regardless of score. Trust beats crowd signal. - The viewer's own templates.
- Authors already in the viewer's personal block list — already hidden by Phase 1, so blur is moot.
UX
Default state (blurred card)
┌────────────────────────────────────┐
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← blurred thumbnail (CSS filter)
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ │
│ ⚠ Flagged by 3 people you │
│ follow · spam │
│ │
│ [ Show anyway ] │
└────────────────────────────────────┘
Untitled template
Author · category
- Thumbnail:
filter: blur(14px) saturate(0.5);— recognizable as content, not readable. - Title and author: rendered normally below the thumbnail (so the viewer can decide based on author).
- Overlay copy: shows count + dominant reason. Dominant reason is the most-common
reasontag among the hop-1 reports, falling back to "muted" if only mute-list signals are present. - Show button: stops propagation, removes the blur class, persists for the session (see "Reveal persistence" below).
Reveal persistence
- A revealed card stays revealed for the current modal session.
- Reload or close-and-reopen the Community modal → cards re-blur.
- No persistent "trust this card" affordance in v1. (Could be added later via a session/local key.)
Settings
One new toggle under Community → Privacy:
[x] Soft-moderate by web of trust
Blur cards that ≥2 people you follow have muted or reported.
Only applies when signed in.
Default: on for logged-in users. Stored at localStorage['casewrap:wot:softmod:enabled'] = '1' | '0'.
Logged-out behavior
- Toggle hidden.
- No soft moderation applied (no WoT to weight against).
- Personal block list (Phase 1) still works.
Engineering notes
Where it slots into the pipeline
buildRowsFromEvents
└── row.trust = trustTierForPubkey(...) ← already exists
└── row.softMod = computeSoftMod(row) ← NEW
├── if !state.community.myPubkey → null
├── if !softModEnabled → null
├── if row.trust === 'trusted' || 'self' → null
├── if _blockedAuthors.has(...) → never reaches here (filtered earlier)
└── return { score, reason, count } | null
buildFeedCard reads row.softMod and toggles a .feedCardBlurred class + injects the overlay HTML.
Fetching the signal
Two new batched queries inside fetchFeedAndReactions, gated on state.community.myFollows.length > 0:
// Hop-1 mute lists (latest kind-10000 per author)
queryRelays(relays, {
kinds: [10000],
authors: state.community.myFollows.slice(0, MAX_HOP1_FOR_SOFTMOD)
}, { timeoutMs: 3500, onEvent: onHop1MuteIncoming });
// Hop-1 reports (kind-1984 by anyone in hop-1, p-tagging any author in the feed)
queryRelays(relays, {
kinds: [1984],
authors: state.community.myFollows.slice(0, MAX_HOP1_FOR_SOFTMOD)
}, { timeoutMs: 3500, onEvent: onHop1ReportIncoming });
MAX_HOP1_FOR_SOFTMOD = 500— same cap pattern as hop-2 follows.- Both queries use the existing streaming
onEventcallbacks so soft-mod state arrives progressively andonProgressre-renders pick it up. - Results accumulate into a
Map<authorPubkey, { mutedBy: Set<reporterPubkey>, reportedBy: Map<reporterPubkey, reason>, eventReports: Map<eventId, Set<reporterPubkey>> }>cached on_feedCache.softMod. - For replaceable kind-10000, only the latest per author counts (handled by
created_atdesc + first-wins).
Computing row.softMod
function computeSoftMod(row) {
if (!softModEnabled() || !state.community.myPubkey) return null;
if (row.trust === 'self' || row.trust === 'trusted') return null;
const authorBucket = _feedCache.softMod?.get(row.e?.pubkey);
if (!authorBucket) return null;
const mutedBy = authorBucket.mutedBy?.size || 0;
const reportedBy = authorBucket.reportedBy?.size || 0;
const eventReports = authorBucket.eventReports?.get(row.e?.id)?.size || 0;
// Don't double-count: same reporter muting AND reporting = 1.
const reporters = new Set([
...(authorBucket.mutedBy || []),
...(authorBucket.reportedBy?.keys() || []),
...(authorBucket.eventReports?.get(row.e?.id) || [])
]);
if (reporters.size < SOFT_BLUR_THRESHOLD) return null;
return { count: reporters.size, reason: dominantReason(authorBucket), reporters };
}
Caching
- Soft-mod state is per-modal-session. Cleared when the Community modal closes.
- Hop-1 mute lists could be cached longer (15 min like hop-2 follows) but in v1 we re-fetch each modal open. Cheap enough.
Phased delivery
Phase A — Data fetch only (no UI)
- Add
MAX_HOP1_FOR_SOFTMOD,SOFT_BLUR_THRESHOLDconstants. - Add
_feedCache.softModMap and aggregation logic. - Add the two streaming queries inside
fetchFeedAndReactions. - Gate on
state.community.myFollows.length > 0.
Acceptance: with a known-flagged author, _feedCache.softMod.get(author).mutedBy.size reads correctly in DevTools.
Phase B — Card rendering
- Add
.feedCardBlurredCSS (blur + saturate on.feedCardThumb img). - Add
.feedCardSoftModOverlayCSS (absolute-positioned label + "Show anyway" button). buildFeedCardchecksrow.softModand applies the class + overlay.- "Show anyway" button removes the class and stores the event id in a session
Set<string>.
Acceptance: with a fixture (manually publish a kind-1984 from two test accounts the viewer follows against an author), the card renders blurred, count and reason match, "Show anyway" reveals it, and reload re-blurs.
Phase C — Settings toggle
- Add the checkbox under Community → Privacy.
- Wire
softModEnabled()to readlocalStorage, defaulttrue. - Hide toggle when logged out.
Acceptance: toggling off immediately removes all blurs without reload; toggling on re-applies after the next rerender.
Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Mob blur (small clique coordinates to dim a creator) | Threshold of 2 from hop-1 — must be from people the viewer personally trusts, not strangers |
| False positives (someone mutes for personal beef, not policy) | Always one-click to reveal; never hide entirely; show count + reason so viewer can judge |
| Performance cost of extra hop-1 queries | Same streaming pipeline as templates; bounded by MAX_HOP1_FOR_SOFTMOD = 500; cacheable |
| Discoverability: revealed cards re-blur on reload | v1 trade-off for simplicity; v2 could add a per-event "trust this card" persisted set |
| Author follows the viewer's followers but they all mute that author | Unlikely in practice; if it happens, viewer can disable soft-mod or unfollow the muters |
| Reason tag missing or malformed on a kind-1984 | Skip that event for dominantReason; still count it toward reporters.size |
| Reporter compromise (someone gets phished, mutes everyone) | Threshold of ≥2 distinct reporters limits single-account damage |
Out of scope / future enhancements
- Reporter reputation: weight reports from accounts the viewer follows more heavily, or downweight serial reporters.
- Hop-2 expansion: optional toggle to count hop-2 reports at a lower weight.
- Reason-specific thresholds: e.g.,
illegaltriggers blur at count=1,spamrequires count=3. - Persistent reveal: per-event "I've decided this is fine" with
localStorage. - Author-level overlay: replace thumbnail blur with a full-card "Tap to expand" placeholder for severe categories.
- Surface own reports: "You and 2 others flagged this" feels different from "3 others flagged this".
- Block-from-blur: add a "Block author" button inside the overlay (currently the card's normal Block button does it).
Open questions to resolve before build
- Should hop-1 mute counts include private mute lists? NIP-51 supports encrypted
contentfor private mutes — for v1 we only read publicptags. - Should soft-mod re-fetch run on every modal open or only on first open per session? Defaulting to every open for v1 — measure cost, downgrade to once-per-session if it hurts.
- Should the overlay show who (e.g., "Flagged by alice, bob")? Probably not by default — turns moderation into social pressure. Leave as count + reason only.
- Does "Show anyway" persist across tab switches in the Community modal? Yes — session-scoped, not tab-scoped.