mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-03 12:10:39 +00:00
Add WoT soft-moderation (blur-by-WoT) + fix feed scroll and d-tag collisions
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]>
This commit is contained in:
1 parent
e4e5671c85
commit
b02e826481
2 files changed
+430
-19
No files matched your search
@@ -0,0 +1,239 @@
|
||||
# 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
|
||||
1. Reduce the visual surface area of suspected spam / abuse for logged-in users.
|
||||
2. Use only Nostr-native data already being published.
|
||||
3. Stay non-censorial: every soft-moderated card is one click away from full view.
|
||||
4. 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 a `p` tag.
|
||||
- `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 `reason` tag 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`:
|
||||
|
||||
```javascript
|
||||
// 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 `onEvent` callbacks so soft-mod state arrives progressively and `onProgress` re-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_at` desc + first-wins).
|
||||
|
||||
### Computing `row.softMod`
|
||||
```javascript
|
||||
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_THRESHOLD` constants.
|
||||
- Add `_feedCache.softMod` Map 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 `.feedCardBlurred` CSS (blur + saturate on `.feedCardThumb img`).
|
||||
- Add `.feedCardSoftModOverlay` CSS (absolute-positioned label + "Show anyway" button).
|
||||
- `buildFeedCard` checks `row.softMod` and 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 read `localStorage`, default `true`.
|
||||
- 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., `illegal` triggers blur at count=1, `spam` requires 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
|
||||
|
||||
1. **Should hop-1 mute counts include private mute lists?** NIP-51 supports encrypted `content` for private mutes — for v1 we only read public `p` tags.
|
||||
2. **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.
|
||||
3. **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.
|
||||
4. **Does "Show anyway" persist across tab switches in the Community modal?** Yes — session-scoped, not tab-scoped.
|
||||
+191
-19
@@ -1443,7 +1443,7 @@
|
||||
/* Feed wall */
|
||||
.feedLoadStatus{font-size:.78rem;color:var(--muted);padding:2px 2px 6px;font-style:italic;letter-spacing:.02em}
|
||||
.feedLoadStatus[hidden]{display:none}
|
||||
.feedWall{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-top:4px;max-height:68vh;overflow-y:auto;align-content:start}
|
||||
.feedWall{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-top:4px;align-content:start}
|
||||
.feedCard{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#fff;cursor:pointer;display:flex;flex-direction:column;transition:box-shadow .12s}
|
||||
.feedCard:hover{box-shadow:0 4px 18px rgba(15,23,42,.13)}
|
||||
.feedCardThumb{width:100%;aspect-ratio:1;background:#f1f5f9;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
|
||||
@@ -1488,6 +1488,12 @@
|
||||
body.nostr-logged-in .feedTrustToggle{display:flex}
|
||||
.feedTrustBadge{font-size:.62rem;background:rgba(0,184,230,.12);color:var(--accent);border:1px solid rgba(0,184,230,.25);border-radius:4px;padding:1px 5px;margin-left:5px;white-space:nowrap;flex-shrink:0}
|
||||
body.dark .feedTrustBadge{background:rgba(0,217,255,.1);border-color:rgba(0,217,255,.2)}
|
||||
.feedCard.feedCardBlurred .feedCardThumb img,
|
||||
.feedCard.feedCardBlurred .feedCardThumb canvas{filter:blur(14px) saturate(.5);transition:filter .15s}
|
||||
.feedCardSoftModOverlay{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;background:rgba(15,23,42,.55);color:#fff;padding:12px;text-align:center;font-size:.78rem;line-height:1.3}
|
||||
.feedCardSoftModOverlay .softModLabel{font-weight:600;max-width:90%}
|
||||
.feedCardSoftModOverlay button{font-size:.74rem;padding:5px 12px;border-radius:6px;border:1px solid rgba(255,255,255,.5);background:rgba(255,255,255,.12);color:#fff;cursor:pointer}
|
||||
.feedCardSoftModOverlay button:hover{background:rgba(255,255,255,.22)}
|
||||
.feedTypeBtn{border:1px solid var(--line);border-radius:20px;padding:4px 13px;background:#fff;color:var(--muted);cursor:pointer;font-size:.78rem;font-weight:700}
|
||||
.feedTypeBtn.active{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
body.dark .feedTypeBtn{background:#0b1220;color:var(--muted);border-color:var(--line)}
|
||||
@@ -2386,6 +2392,13 @@
|
||||
<p class="hint">Blocked authors are hidden in all community feed views.</p>
|
||||
<div id="blockListContainer" class="hint">No blocked authors yet.</div>
|
||||
</details>
|
||||
<details id="softModDetails" style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px;display:none">
|
||||
<summary style="font-weight:700;font-size:.82rem;cursor:pointer;color:var(--muted)">Web-of-trust soft moderation</summary>
|
||||
<label style="display:flex;align-items:flex-start;gap:8px;margin-top:8px;font-size:.78rem;cursor:pointer">
|
||||
<input type="checkbox" id="softModEnabledToggle" style="accent-color:var(--accent);margin-top:2px" />
|
||||
<span>Blur cards that ≥2 people you follow have muted or reported. Click <em>Show anyway</em> to reveal.</span>
|
||||
</label>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4964,8 +4977,10 @@
|
||||
throw new Error('Load cover artwork before publishing.');
|
||||
}
|
||||
}
|
||||
const mode = normalizeTemplateMode(payload);
|
||||
const dBase = state.meta.imdbId || state.meta.title;
|
||||
const tags = [
|
||||
['d', state.meta.imdbId || state.meta.title],
|
||||
['d', `${mode}:${dBase}`],
|
||||
...getTemplateTags(payload, typeTag)
|
||||
];
|
||||
const evt = { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags, content: JSON.stringify(payload) };
|
||||
@@ -4986,7 +5001,7 @@
|
||||
throw new Error('Add a title in Project Metadata or a custom design name before publishing.');
|
||||
}
|
||||
const tags = [
|
||||
['d', `${state.meta.imdbId || designName}`],
|
||||
['d', `disc-design:${state.meta.imdbId || designName}`],
|
||||
...getTemplateTags(payload, 'casewrap-disc-design'),
|
||||
['title', designName]
|
||||
];
|
||||
@@ -5196,6 +5211,18 @@
|
||||
|
||||
const HOP2_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
const HOP2_MAX_HOP1_NODES = 500;
|
||||
const MAX_HOP1_FOR_SOFTMOD = 500;
|
||||
const SOFT_BLUR_THRESHOLD = 2;
|
||||
const SOFT_MOD_ENABLED_KEY = 'casewrap:wot:softmod:enabled';
|
||||
|
||||
function softModEnabled() {
|
||||
if (!normalizePubkey(state.community.myPubkey)) return false;
|
||||
const raw = localStorage.getItem(SOFT_MOD_ENABLED_KEY);
|
||||
return raw === null ? true : raw === '1';
|
||||
}
|
||||
function setSoftModEnabled(on) {
|
||||
localStorage.setItem(SOFT_MOD_ENABLED_KEY, on ? '1' : '0');
|
||||
}
|
||||
|
||||
function hop2CacheKey(pubkey) { return `casewrap:wot:hop2:${pubkey}`; }
|
||||
|
||||
@@ -5252,6 +5279,96 @@
|
||||
saveCachedHop2(myPubkey, extended);
|
||||
}
|
||||
|
||||
function ensureSoftModBucket(authorPubkey) {
|
||||
let bucket = _feedCache.softMod.get(authorPubkey);
|
||||
if (!bucket) {
|
||||
bucket = { mutedBy: new Set(), reportedBy: new Map(), eventReports: new Map() };
|
||||
_feedCache.softMod.set(authorPubkey, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
async function fetchSoftModSignal(relays, templateEvents) {
|
||||
const myPubkey = normalizePubkey(state.community.myPubkey);
|
||||
if (!myPubkey || !state.community.myFollows.length) return;
|
||||
const hop1 = state.community.myFollows.slice(0, MAX_HOP1_FOR_SOFTMOD);
|
||||
const authorsInFeed = new Set(templateEvents.map(e => e.pubkey).filter(Boolean));
|
||||
const eventIdsInFeed = new Set(templateEvents.map(e => e.id).filter(Boolean));
|
||||
const latestMute = new Map();
|
||||
const BATCH = 50;
|
||||
const muteRequests = [];
|
||||
const reportRequests = [];
|
||||
for (let i = 0; i < hop1.length; i += BATCH) {
|
||||
const slice = hop1.slice(i, i + BATCH);
|
||||
muteRequests.push(queryRelays(relays, { kinds: [10000], authors: slice }, { timeoutMs: 3500 }));
|
||||
reportRequests.push(queryRelays(relays, { kinds: [1984], authors: slice, limit: 500 }, { timeoutMs: 3500 }));
|
||||
}
|
||||
const [muteBatches, reportBatches] = await Promise.all([
|
||||
Promise.all(muteRequests),
|
||||
Promise.all(reportRequests)
|
||||
]);
|
||||
for (const events of muteBatches) {
|
||||
for (const e of events) {
|
||||
const prev = latestMute.get(e.pubkey);
|
||||
if (!prev || e.created_at > prev.created_at) latestMute.set(e.pubkey, e);
|
||||
}
|
||||
}
|
||||
for (const muteEvent of latestMute.values()) {
|
||||
const reporter = normalizePubkey(muteEvent.pubkey);
|
||||
if (!reporter) continue;
|
||||
for (const tag of (muteEvent.tags || [])) {
|
||||
if (tag[0] !== 'p' || !tag[1]) continue;
|
||||
const author = normalizePubkey(tag[1]);
|
||||
if (!author || !authorsInFeed.has(author)) continue;
|
||||
ensureSoftModBucket(author).mutedBy.add(reporter);
|
||||
}
|
||||
}
|
||||
for (const events of reportBatches) {
|
||||
for (const e of events) {
|
||||
const reporter = normalizePubkey(e.pubkey);
|
||||
if (!reporter) continue;
|
||||
const pTag = (e.tags || []).find(t => t[0] === 'p' && t[1]);
|
||||
const eTag = (e.tags || []).find(t => t[0] === 'e' && t[1]);
|
||||
const author = pTag ? normalizePubkey(pTag[1]) : '';
|
||||
if (!author || !authorsInFeed.has(author)) continue;
|
||||
const reason = (eTag && eTag[2]) || (pTag && pTag[2]) || 'other';
|
||||
const bucket = ensureSoftModBucket(author);
|
||||
if (!bucket.reportedBy.has(reporter)) bucket.reportedBy.set(reporter, reason);
|
||||
if (eTag && eventIdsInFeed.has(eTag[1])) {
|
||||
let evReporters = bucket.eventReports.get(eTag[1]);
|
||||
if (!evReporters) { evReporters = new Set(); bucket.eventReports.set(eTag[1], evReporters); }
|
||||
evReporters.add(reporter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REASON_LABELS = { spam: 'spam', nudity: 'nudity', illegal: 'illegal content', impersonation: 'impersonation', other: 'concerns', muted: 'mute' };
|
||||
|
||||
function dominantReason(bucket) {
|
||||
const counts = new Map();
|
||||
for (const reason of bucket.reportedBy.values()) counts.set(reason, (counts.get(reason) || 0) + 1);
|
||||
if (!counts.size && bucket.mutedBy.size) return 'muted';
|
||||
let best = 'other', bestN = 0;
|
||||
for (const [reason, n] of counts) { if (n > bestN) { best = reason; bestN = n; } }
|
||||
return best;
|
||||
}
|
||||
|
||||
function computeSoftMod(row) {
|
||||
if (!softModEnabled()) return null;
|
||||
if (!row?.e?.pubkey) return null;
|
||||
if (row.trust === 'self' || row.trust === 'trusted') return null;
|
||||
const bucket = _feedCache.softMod?.get(row.e.pubkey);
|
||||
if (!bucket) return null;
|
||||
const reporters = new Set([
|
||||
...bucket.mutedBy,
|
||||
...bucket.reportedBy.keys(),
|
||||
...((bucket.eventReports.get(row.e.id) || new Set()))
|
||||
]);
|
||||
if (reporters.size < SOFT_BLUR_THRESHOLD) return null;
|
||||
return { count: reporters.size, reason: dominantReason(bucket) };
|
||||
}
|
||||
|
||||
async function syncLocalBlocksToMuteList(actionLabel = 'update') {
|
||||
if (!normalizePubkey(state.community.myPubkey)) return null;
|
||||
const muted = [..._blockedAuthors].sort();
|
||||
@@ -5809,7 +5926,7 @@
|
||||
}
|
||||
|
||||
|
||||
const _feedCache = { events: null, reactions: {}, profiles: {}, fetchedAt: 0, loading: false, relayKey: '', relayProgress: { responded: 0, total: 0 }, phase: '' };
|
||||
const _feedCache = { events: null, reactions: {}, profiles: {}, fetchedAt: 0, loading: false, relayKey: '', relayProgress: { responded: 0, total: 0 }, phase: '', softMod: new Map(), softModRevealed: new Set() };
|
||||
let _currentFeedRerender = null;
|
||||
const FEED_PROGRESS_DEBOUNCE_MS = 150;
|
||||
|
||||
@@ -5890,6 +6007,7 @@
|
||||
_feedCache.relayKey = relayKey;
|
||||
_feedCache.reactions = {};
|
||||
_feedCache.profiles = {};
|
||||
_feedCache.softMod = new Map();
|
||||
const activeRelays = getActiveRelays(relays, { includeCoolingDown: false });
|
||||
_feedCache.relayProgress = { responded: 0, total: activeRelays.length };
|
||||
_feedCache.phase = 'templates';
|
||||
@@ -5962,6 +6080,11 @@
|
||||
}
|
||||
}
|
||||
notifyFeedProgress();
|
||||
|
||||
if (softModEnabled() && state.community.myFollows.length) {
|
||||
await fetchSoftModSignal(relays, templateEvents);
|
||||
notifyFeedProgress();
|
||||
}
|
||||
}
|
||||
_feedCache.fetchedAt = Date.now();
|
||||
} finally {
|
||||
@@ -5997,6 +6120,10 @@
|
||||
if (selNew) selNew.value = 'all';
|
||||
if (selPop) selPop.value = 'all';
|
||||
}
|
||||
const softModBlock = $('softModDetails');
|
||||
if (softModBlock) softModBlock.style.display = loggedIn ? '' : 'none';
|
||||
const softModToggle = $('softModEnabledToggle');
|
||||
if (softModToggle) softModToggle.checked = softModEnabled();
|
||||
}
|
||||
const _blockedAuthors = new Set();
|
||||
let _lastBlockedPubkey = '';
|
||||
@@ -6252,7 +6379,8 @@
|
||||
const hay = `${displayTitle} ${title} ${imdb} ${category} ${language} ${(e.tags || []).map(t => t.join(' ')).join(' ')}`.toLowerCase();
|
||||
if (!hay.includes(q)) continue;
|
||||
}
|
||||
const existing = address ? latestByAddress.get(address) : null;
|
||||
const uiKey = address ? `${address}|${mode}` : '';
|
||||
const existing = uiKey ? latestByAddress.get(uiKey) : null;
|
||||
const row = {
|
||||
e, payload,
|
||||
title: displayTitle || `(${mode})`,
|
||||
@@ -6266,9 +6394,10 @@
|
||||
createdAt: e.created_at || 0,
|
||||
trust: trustTierForPubkey(e.pubkey)
|
||||
};
|
||||
if (address) {
|
||||
row.softMod = computeSoftMod(row);
|
||||
if (uiKey) {
|
||||
if (!existing) {
|
||||
latestByAddress.set(address, row);
|
||||
latestByAddress.set(uiKey, row);
|
||||
}
|
||||
} else {
|
||||
rows.push(row);
|
||||
@@ -6280,8 +6409,9 @@
|
||||
function deduplicateRows(rows) {
|
||||
const seen = new Map();
|
||||
for (const row of rows) {
|
||||
const key = row.address || row.imdb || row.title?.toLowerCase().trim() || '';
|
||||
if (!key) { seen.set(row.e.id, row); continue; }
|
||||
const baseKey = row.address || row.imdb || row.title?.toLowerCase().trim() || '';
|
||||
if (!baseKey) { seen.set(row.e.id, row); continue; }
|
||||
const key = `${baseKey}|${row.mode}`;
|
||||
const existing = seen.get(key);
|
||||
if (!existing || row.reactions > existing.reactions) seen.set(key, row);
|
||||
}
|
||||
@@ -6291,6 +6421,9 @@
|
||||
function buildFeedCard(row, onUse) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'feedCard';
|
||||
const isRevealed = row.e?.id && _feedCache.softModRevealed.has(row.e.id);
|
||||
const showBlur = row.softMod && !isRevealed;
|
||||
if (showBlur) el.classList.add('feedCardBlurred');
|
||||
const isDisc = row.mode === 'disc' || row.mode === 'disc-design';
|
||||
const modeLabel = MODE_LABELS[row.mode] || row.mode;
|
||||
const thumb = document.createElement('div');
|
||||
@@ -6309,6 +6442,27 @@
|
||||
img.addEventListener('error', () => img.remove());
|
||||
thumb.insertBefore(img, lbl);
|
||||
}
|
||||
if (showBlur) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'feedCardSoftModOverlay';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'softModLabel';
|
||||
const reasonLabel = REASON_LABELS[row.softMod.reason] || row.softMod.reason;
|
||||
label.textContent = row.softMod.reason === 'muted'
|
||||
? `Muted by ${row.softMod.count} people you follow`
|
||||
: `Flagged by ${row.softMod.count} people you follow · ${reasonLabel}`;
|
||||
const revealBtn = document.createElement('button');
|
||||
revealBtn.type = 'button';
|
||||
revealBtn.textContent = 'Show anyway';
|
||||
revealBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (row.e?.id) _feedCache.softModRevealed.add(row.e.id);
|
||||
el.classList.remove('feedCardBlurred');
|
||||
overlay.remove();
|
||||
});
|
||||
overlay.append(label, revealBtn);
|
||||
thumb.appendChild(overlay);
|
||||
}
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'feedCardMeta';
|
||||
const authorProfile = _feedCache.profiles?.[row.e?.pubkey] || null;
|
||||
@@ -6348,7 +6502,10 @@
|
||||
});
|
||||
actions.append(makeReactionBtn(row), prevBtn, btn, blockBtn, reportBtn);
|
||||
el.append(thumb, meta, actions);
|
||||
el.addEventListener('click', () => openDesignPreview(row));
|
||||
el.addEventListener('click', () => {
|
||||
if (el.classList.contains('feedCardBlurred')) return;
|
||||
openDesignPreview(row);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -6494,6 +6651,10 @@
|
||||
$('nostrModal').classList.remove('open');
|
||||
}));
|
||||
});
|
||||
const scroller = wall.closest('.modalCard');
|
||||
if (scroller && page.rendered < page.rows.length && scroller.scrollHeight <= scroller.clientHeight + 40) {
|
||||
requestAnimationFrame(() => appendFeedRows(tab));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6501,14 +6662,15 @@
|
||||
const page = _feedPagination[tab];
|
||||
const wall = $(`feedWall${tab}`);
|
||||
if (!page || !wall || page.initialized) return;
|
||||
wall.addEventListener('scroll', () => {
|
||||
if (wall.scrollHeight <= wall.clientHeight) {
|
||||
appendFeedRows(tab);
|
||||
return;
|
||||
}
|
||||
if ((wall.scrollTop + wall.clientHeight) / wall.scrollHeight > 0.8) appendFeedRows(tab);
|
||||
});
|
||||
const scroller = wall.closest('.modalCard') || wall.parentElement;
|
||||
const onScroll = () => {
|
||||
if (!scroller) return;
|
||||
const distanceFromBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||
if (distanceFromBottom < scroller.clientHeight * 0.4) appendFeedRows(tab);
|
||||
};
|
||||
if (scroller) scroller.addEventListener('scroll', onScroll);
|
||||
page.initialized = true;
|
||||
page.scrollHandler = onScroll;
|
||||
}
|
||||
|
||||
async function loadFeedTab(tab) {
|
||||
@@ -7642,8 +7804,9 @@
|
||||
: state.templateMode === 'jewel'
|
||||
? 'casewrap-jewel'
|
||||
: 'casewrap-cover';
|
||||
const clipboardMode = normalizeTemplateMode(payload);
|
||||
const tags = [
|
||||
['d', state.meta.imdbId || state.meta.title || 'template'],
|
||||
['d', `${clipboardMode}:${state.meta.imdbId || state.meta.title || 'template'}`],
|
||||
...getTemplateTags(payload, typeTag)
|
||||
];
|
||||
const evt = { kind: 30078, created_at: Math.floor(Date.now()/1000), tags, content: JSON.stringify(payload) };
|
||||
@@ -7653,7 +7816,7 @@
|
||||
alert("Signed Nostr event copied to clipboard");
|
||||
});
|
||||
|
||||
$("nostrClose").addEventListener("click", () => { closeNostrPreview(); $("nostrModal").classList.remove("open"); });
|
||||
$("nostrClose").addEventListener("click", () => { closeNostrPreview(); $("nostrModal").classList.remove("open"); _feedCache.softModRevealed.clear(); });
|
||||
$("nostrRefreshBtn").addEventListener("click", async () => {
|
||||
const btn = $("nostrRefreshBtn");
|
||||
const original = btn.textContent;
|
||||
@@ -8165,6 +8328,15 @@
|
||||
$('reportSubmitBtn').addEventListener('click', submitReport);
|
||||
$('reportCancelBtn').addEventListener('click', closeReportModal);
|
||||
$('reportModal').addEventListener('click', e => { if (e.target === $('reportModal')) closeReportModal(); });
|
||||
const softModToggle = $('softModEnabledToggle');
|
||||
if (softModToggle) {
|
||||
softModToggle.checked = softModEnabled();
|
||||
softModToggle.addEventListener('change', () => {
|
||||
setSoftModEnabled(softModToggle.checked);
|
||||
_feedCache.fetchedAt = 0;
|
||||
refreshCommunityView();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Reference in new issue
Block a user