diff --git a/docs/WOT_SOFT_MODERATION_FEATURE.md b/docs/WOT_SOFT_MODERATION_FEATURE.md new file mode 100644 index 0000000..034cd73 --- /dev/null +++ b/docs/WOT_SOFT_MODERATION_FEATURE.md @@ -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, reportedBy: Map, eventReports: Map> }>` 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`. + +**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. diff --git a/src/index.html b/src/index.html index d7fbdce..77742ae 100644 --- a/src/index.html +++ b/src/index.html @@ -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 @@

Blocked authors are hidden in all community feed views.

No blocked authors yet.
+ @@ -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();