Add Web-of-Trust phases 3-4: trust ranking, hop-2 graph, and NIP-56 reporting

Phase 3:
- Streaming relay sync with progressive feed rendering (onEvent callbacks, 150ms debounce)
- Read-side soft cooldown (30s base, 5min max) so transient timeouts don't lock relays for an hour
- NIP-09 deletion fix: timestamp-aware tombstoning so re-published designs appear correctly
- Hop-2 follow graph (fetchHop2Follows) with 15min sessionStorage cache
- 4-tier trust system: self/trusted/extended/unknown with TIER_WEIGHT sort
- Trust filter select (All/Follows/Follows+Extended) on New and Popular tabs
- Trust badges on feed cards (Following / 2nd°) when logged in
- Streaming status line showing relay progress during fetch

Phase 4:
- NIP-56 kind-1984 report flow: modal with reason, note, and "also block" option
- Report button on each feed card alongside Block
- reportModal moved before <script> so DOM is ready when init() wires listeners

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Sonnet 4.6 committed 2026-05-16 14:29:24 -04:00
1 parent bb93207871
commit e4e5671c85
1 file changed
+229 -7
+229 -7
View File
@@ -1481,7 +1481,13 @@
.resultAuthorName{font-size:.68rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* Feed type filter pills */
.feedTypeFilter{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
.feedTypeFilter{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
.feedTrustToggle{display:none;align-items:center;gap:5px;font-size:.78rem;color:var(--muted);margin-left:auto;white-space:nowrap;user-select:none}
.feedTrustToggle select{font-size:.75rem;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:4px;padding:3px 6px;cursor:pointer}
body.dark .feedTrustToggle select{background:#0b1220}
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)}
.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)}
@@ -2287,6 +2293,13 @@
<option value="zh">Chinese</option>
<option value="other">Other</option>
</select>
<label class="feedTrustToggle" title="Filter by web-of-trust">Trust:
<select id="trustFilterNew">
<option value="all">All</option>
<option value="trusted">Follows</option>
<option value="extended">Follows + Extended</option>
</select>
</label>
</div>
<div class="feedLoadStatus" id="feedStatusNew" hidden></div>
<div class="feedWall" id="feedWallNew"></div>
@@ -2313,6 +2326,13 @@
<option value="zh">Chinese</option>
<option value="other">Other</option>
</select>
<label class="feedTrustToggle" title="Filter by web-of-trust">Trust:
<select id="trustFilterPopular">
<option value="all">All</option>
<option value="trusted">Follows</option>
<option value="extended">Follows + Extended</option>
</select>
</label>
</div>
<div class="feedLoadStatus" id="feedStatusPopular" hidden></div>
<div class="feedWall" id="feedWallPopular"></div>
@@ -2370,6 +2390,32 @@
</div>
<div class="modal" id="reportModal" aria-hidden="true" style="display:none">
<div class="modalInner" style="max-width:420px">
<h2 style="font-size:1rem;margin:0 0 14px;font-family:inherit">Report template</h2>
<label style="display:block;margin-bottom:10px">Reason
<select id="reportReason" style="display:block;width:100%;margin-top:4px">
<option value="spam">Spam</option>
<option value="nudity">Nudity / explicit content</option>
<option value="illegal">Illegal content</option>
<option value="impersonation">Impersonation</option>
<option value="other">Other</option>
</select>
</label>
<label style="display:block;margin-bottom:10px">Note <span class="hint" style="font-size:.75rem">(optional)</span>
<textarea id="reportNote" rows="2" placeholder="Any details…" style="display:block;width:100%;margin-top:4px;resize:vertical"></textarea>
</label>
<label style="display:flex;align-items:center;gap:7px;margin-bottom:14px;cursor:pointer">
<input type="checkbox" id="reportAlsoBlock" style="accent-color:var(--accent)"> Also block this author
</label>
<div class="buttonRow" style="margin:0">
<button type="button" id="reportSubmitBtn" class="primary">Send report</button>
<button type="button" id="reportCancelBtn">Cancel</button>
</div>
<p class="hint" id="reportStatus" style="margin-top:10px;min-height:1.2em"></p>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const safe$ = (id) => { try { return $(id); } catch { return null; } };
@@ -2557,6 +2603,8 @@
myPubkey: '',
myProfile: null,
myFollows: [],
myExtendedFollows: new Set(),
hop2FetchedAt: 0,
mutedPubkeys: [],
muteListSyncedAt: 0,
relayHealth: loadRelayHealth(),
@@ -5146,6 +5194,64 @@
return merged;
}
const HOP2_CACHE_TTL_MS = 15 * 60 * 1000;
const HOP2_MAX_HOP1_NODES = 500;
function hop2CacheKey(pubkey) { return `casewrap:wot:hop2:${pubkey}`; }
function loadCachedHop2(pubkey) {
try {
const raw = sessionStorage.getItem(hop2CacheKey(pubkey));
if (!raw) return null;
const { pubkeys, fetchedAt } = JSON.parse(raw);
if (Date.now() - fetchedAt > HOP2_CACHE_TTL_MS) return null;
return new Set(pubkeys);
} catch { return null; }
}
function saveCachedHop2(pubkey, set) {
try {
sessionStorage.setItem(hop2CacheKey(pubkey), JSON.stringify({ pubkeys: [...set], fetchedAt: Date.now() }));
} catch {}
}
async function fetchHop2Follows() {
const myPubkey = normalizePubkey(state.community.myPubkey);
if (!myPubkey || !state.community.myFollows.length) {
state.community.myExtendedFollows = new Set();
return;
}
const cached = loadCachedHop2(myPubkey);
if (cached) {
state.community.myExtendedFollows = cached;
state.community.hop2FetchedAt = Date.now();
return;
}
const hop1 = state.community.myFollows.slice(0, HOP2_MAX_HOP1_NODES);
const relays = splitRelayList(state.community.relays);
const BATCH = 50;
const extended = new Set();
const followsSet = new Set(state.community.myFollows);
const batches = [];
for (let i = 0; i < hop1.length; i += BATCH) batches.push(hop1.slice(i, i + BATCH));
const resultSets = await Promise.all(
batches.map(authors => queryRelays(relays, { kinds: [3], authors, limit: 500 }, { timeoutMs: 5000 }))
);
for (const events of resultSets) {
for (const e of events) {
for (const tag of (e.tags || [])) {
if (tag[0] === 'p' && tag[1]) {
const pk = normalizePubkey(tag[1]);
if (pk && pk !== myPubkey && !followsSet.has(pk)) extended.add(pk);
}
}
}
}
state.community.myExtendedFollows = extended;
state.community.hop2FetchedAt = Date.now();
saveCachedHop2(myPubkey, extended);
}
async function syncLocalBlocksToMuteList(actionLabel = 'update') {
if (!normalizePubkey(state.community.myPubkey)) return null;
const muted = [..._blockedAuthors].sort();
@@ -5191,6 +5297,7 @@
} catch (error) {
state.community.status = 'Signed in, but mute-list sync could not be loaded.';
}
fetchHop2Follows().catch(() => {});
syncCommunityInputs();
updateNostrWidget();
if ($('nostrModal')?.classList.contains('open')) {
@@ -5864,7 +5971,33 @@
}
}
const _feedState = { filterNew: 'any', filterPopular: 'any', filterMine: 'any', categoryNew: '', categoryPopular: '', categoryMine: '', languageNew: '', languagePopular: '', languageMine: '' };
const _feedState = { filterNew: 'any', filterPopular: 'any', filterMine: 'any', categoryNew: '', categoryPopular: '', categoryMine: '', languageNew: '', languagePopular: '', languageMine: '', trustFilterNew: 'all', trustFilterPopular: 'all' };
const TIER_WEIGHT = { self: 0, trusted: 1, extended: 2, unknown: 3 };
function trustTierForPubkey(pubkey) {
const normalized = normalizePubkey(pubkey);
if (!normalized) return 'unknown';
const myPubkey = normalizePubkey(state.community.myPubkey);
if (!myPubkey) return 'unknown';
if (normalized === myPubkey) return 'self';
if (state.community.myFollows.includes(normalized)) return 'trusted';
if (state.community.myExtendedFollows?.has(normalized)) return 'extended';
return 'unknown';
}
function updateTrustToggles() {
const loggedIn = !!normalizePubkey(state.community.myPubkey);
document.body.classList.toggle('nostr-logged-in', loggedIn);
if (!loggedIn) {
_feedState.trustFilterNew = 'all';
_feedState.trustFilterPopular = 'all';
const selNew = $('trustFilterNew');
const selPop = $('trustFilterPopular');
if (selNew) selNew.value = 'all';
if (selPop) selPop.value = 'all';
}
}
const _blockedAuthors = new Set();
let _lastBlockedPubkey = '';
@@ -5990,6 +6123,64 @@
await refreshCommunityView();
}
}
let _reportTarget = null;
function openReportModal(eventId, authorPubkey) {
_reportTarget = { eventId, authorPubkey };
const modal = $('reportModal');
const status = $('reportStatus');
const submitBtn = $('reportSubmitBtn');
if (!modal) return;
$('reportReason').value = 'spam';
$('reportNote').value = '';
$('reportAlsoBlock').checked = false;
if (status) { status.textContent = ''; status.style.color = ''; }
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Send report'; }
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
}
function closeReportModal() {
const modal = $('reportModal');
if (modal) { modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); }
_reportTarget = null;
}
async function submitReport() {
if (!_reportTarget) return;
if (!window.nostr) { alert('Install a Nostr extension (like Alby or nos2x) to send reports.'); return; }
const { eventId, authorPubkey } = _reportTarget;
const reason = $('reportReason')?.value || 'other';
const note = ($('reportNote')?.value || '').trim();
const alsoBlock = $('reportAlsoBlock')?.checked;
const statusEl = $('reportStatus');
const submitBtn = $('reportSubmitBtn');
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Sending…'; }
try {
const evt = {
kind: 1984,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', eventId, reason],
['p', authorPubkey]
],
content: note
};
const signed = await signNostrEvent(evt);
await publishToRelays(signed);
if (statusEl) { statusEl.textContent = 'Report sent.'; statusEl.style.color = 'var(--ok)'; }
if (alsoBlock && authorPubkey) {
closeReportModal();
await blockAuthor(authorPubkey, 'report');
} else {
setTimeout(closeReportModal, 1200);
}
} catch (e) {
if (statusEl) { statusEl.textContent = e.message || 'Could not send report.'; statusEl.style.color = 'var(--warn)'; }
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Send report'; }
}
}
const _feedPagination = {
New: { rows: [], rendered: 0, chunk: 24, initialized: false },
Popular: { rows: [], rendered: 0, chunk: 24, initialized: false }
@@ -6072,7 +6263,8 @@
address,
preview: previewFromPayload(payload, mode),
reactions: _feedCache.reactions[e.id] || 0,
createdAt: e.created_at || 0
createdAt: e.created_at || 0,
trust: trustTierForPubkey(e.pubkey)
};
if (address) {
if (!existing) {
@@ -6122,7 +6314,13 @@
const authorProfile = _feedCache.profiles?.[row.e?.pubkey] || null;
const authorName = authorProfile?.name || authorProfile?.display_name || (row.e?.pubkey ? shortPubkey(row.e.pubkey) : '');
const authorPic = authorProfile?.picture || authorProfile?.image || '';
const authorHtml = authorName ? `<div class="feedCardAuthor">${authorPic ? `<img class="feedCardAvatar" src="${escapeHtml(authorPic)}" alt="" onerror="this.remove()" loading="lazy" />` : ''}<span class="feedCardAuthorName">${escapeHtml(authorName)}</span></div>` : '';
const loggedIn = !!normalizePubkey(state.community.myPubkey);
const trustBadgeHtml = loggedIn && row.trust === 'trusted'
? '<span class="feedTrustBadge">Following</span>'
: loggedIn && row.trust === 'extended'
? '<span class="feedTrustBadge" style="opacity:.7">2nd°</span>'
: '';
const authorHtml = authorName ? `<div class="feedCardAuthor">${authorPic ? `<img class="feedCardAvatar" src="${escapeHtml(authorPic)}" alt="" onerror="this.remove()" loading="lazy" />` : ''}<span class="feedCardAuthorName">${escapeHtml(authorName)}</span>${trustBadgeHtml}</div>` : '';
meta.innerHTML = `<div class="feedCardTitle" title="${escapeHtml(row.title || 'Untitled')}">${escapeHtml(row.title || 'Untitled')}</div><div class="feedCardSub">${escapeHtml([modeLabel, categoryLabel(row.category), row.imdb].filter(Boolean).join(' · '))}</div>${authorHtml}`;
const actions = document.createElement('div');
actions.className = 'feedCardActions';
@@ -6140,7 +6338,15 @@
e.stopPropagation();
await blockAuthor(row.e?.pubkey, 'card');
});
actions.append(makeReactionBtn(row), prevBtn, btn, blockBtn);
const reportBtn = document.createElement('button');
reportBtn.type = 'button';
reportBtn.textContent = 'Report';
reportBtn.title = 'Report this template';
reportBtn.addEventListener('click', e => {
e.stopPropagation();
openReportModal(row.e?.id, row.e?.pubkey);
});
actions.append(makeReactionBtn(row), prevBtn, btn, blockBtn, reportBtn);
el.append(thumb, meta, actions);
el.addEventListener('click', () => openDesignPreview(row));
return el;
@@ -6165,6 +6371,7 @@
loginBtn.style.display = '';
profileBtn.style.display = 'none';
}
updateTrustToggles();
}
async function nostrLogin() {
@@ -6315,11 +6522,14 @@
const categoryFilter = _feedState[`category${tab}`] || '';
const languageFilter = _feedState[`language${tab}`] || '';
let rows = buildRowsFromEvents(_feedCache.events || [], typeFilter, '', false, categoryFilter, languageFilter);
const trustFilter = _feedState[`trustFilter${tab}`] || 'all';
if (trustFilter === 'trusted') rows = rows.filter(r => r.trust === 'self' || r.trust === 'trusted');
else if (trustFilter === 'extended') rows = rows.filter(r => r.trust === 'self' || r.trust === 'trusted' || r.trust === 'extended');
if (tab === 'New') {
rows.sort((a, b) => b.createdAt - a.createdAt);
rows.sort((a, b) => (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
} else {
rows = deduplicateRows(rows);
rows.sort((a, b) => (b.reactions - a.reactions) || (b.createdAt - a.createdAt));
rows.sort((a, b) => (b.reactions - a.reactions) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
}
updateFeedStatus(tab, rows.length);
if (!rows.length) {
@@ -7506,6 +7716,14 @@
});
});
['New', 'Popular'].forEach(tab => {
const sel = $(`trustFilter${tab}`);
if (sel) sel.addEventListener('change', () => {
_feedState[`trustFilter${tab}`] = sel.value;
loadFeedTab(tab);
});
});
$('relayList').addEventListener('change', async (event) => {
state.community.relays = event.target.value.trim() || state.community.relays;
invalidateFeedCache();
@@ -7944,9 +8162,13 @@
syncDesignerInputs();
updateNostrWidget();
render();
$('reportSubmitBtn').addEventListener('click', submitReport);
$('reportCancelBtn').addEventListener('click', closeReportModal);
$('reportModal').addEventListener('click', e => { if (e.target === $('reportModal')) closeReportModal(); });
}
init();
</script>
</body>
</html>