From 62cbc2a2eb72cb7a9c627e8e10963515554b7a96 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 21:47:56 -0400 Subject: [PATCH] Guard user block list from self entries --- components/profile-modal.html | 24 +++ js/app.js | 359 +++++++++++++++++++++++++++++++--- js/subscriptions.js | 21 +- js/userBlocks.js | 282 ++++++++++++++++++++++++++ 4 files changed, 658 insertions(+), 28 deletions(-) create mode 100644 js/userBlocks.js diff --git a/components/profile-modal.html b/components/profile-modal.html index 937467e4..6b0910fc 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -177,6 +177,30 @@ role="tabpanel" aria-labelledby="profileNavBlocked" > +

+ Creators you block are hidden from your BitVid experience. This + list is private to your account. +

+
+ + +
{ + this.handleAddBlockedCreator(); + }); + } + if ( + this.profileBlockedInput && + this.profileBlockedInput.dataset.bound !== "true" + ) { + this.profileBlockedInput.dataset.bound = "true"; + this.profileBlockedInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.handleAddBlockedCreator(); + } + }); + } + if ( this.adminAddModeratorBtn && this.adminAddModeratorBtn.dataset.bound !== "true" @@ -3554,6 +3587,11 @@ class bitvidApp { this.selectProfilePane("account"); this.populateProfileRelays(); + try { + await userBlocks.ensureLoaded(this.pubkey); + } catch (error) { + console.warn("Failed to refresh user block list while opening profile modal:", error); + } this.populateBlockedList(); this.profileModal.classList.remove("hidden"); @@ -3755,15 +3793,88 @@ class bitvidApp { }); } - populateBlockedList(blocked = []) { + populateBlockedList(blocked = null) { if (!this.profileBlockedList || !this.profileBlockedEmpty) { return; } - const entries = Array.isArray(blocked) ? blocked : []; + const sourceEntries = + Array.isArray(blocked) && blocked.length + ? blocked + : userBlocks.getBlockedPubkeys(); + + const normalizedEntries = []; + const pushEntry = (hex, label) => { + if (!hex || !label) { + return; + } + normalizedEntries.push({ hex, label }); + }; + + sourceEntries.forEach((entry) => { + if (typeof entry === "string") { + const trimmed = entry.trim(); + if (!trimmed) { + return; + } + + if (trimmed.startsWith("npub1")) { + const decoded = this.safeDecodeNpub(trimmed); + if (!decoded) { + return; + } + const label = this.safeEncodeNpub(decoded) || trimmed; + pushEntry(decoded, label); + return; + } + + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + const hex = trimmed.toLowerCase(); + const label = this.safeEncodeNpub(hex) || hex; + pushEntry(hex, label); + } + return; + } + + if (entry && typeof entry === "object") { + const candidateNpub = + typeof entry.npub === "string" ? entry.npub.trim() : ""; + const candidateHex = + typeof entry.pubkey === "string" ? entry.pubkey.trim() : ""; + + if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { + const normalizedHex = candidateHex.toLowerCase(); + const label = + candidateNpub && candidateNpub.startsWith("npub1") + ? candidateNpub + : this.safeEncodeNpub(normalizedHex) || normalizedHex; + pushEntry(normalizedHex, label); + return; + } + + if (candidateNpub && candidateNpub.startsWith("npub1")) { + const decoded = this.safeDecodeNpub(candidateNpub); + if (!decoded) { + return; + } + const label = this.safeEncodeNpub(decoded) || candidateNpub; + pushEntry(decoded, label); + } + } + }); + + const deduped = []; + const seenHex = new Set(); + normalizedEntries.forEach((entry) => { + if (!seenHex.has(entry.hex)) { + seenHex.add(entry.hex); + deduped.push(entry); + } + }); + this.profileBlockedList.innerHTML = ""; - if (!entries.length) { + if (!deduped.length) { this.profileBlockedEmpty.classList.remove("hidden"); this.profileBlockedList.classList.add("hidden"); return; @@ -3772,49 +3883,155 @@ class bitvidApp { this.profileBlockedEmpty.classList.add("hidden"); this.profileBlockedList.classList.remove("hidden"); - entries.forEach((entry) => { - const identifier = - typeof entry === "string" - ? entry - : typeof entry?.npub === "string" && entry.npub - ? entry.npub - : typeof entry?.pubkey === "string" - ? entry.pubkey - : ""; - - if (!identifier) { - return; - } - + deduped.forEach(({ hex, label }) => { const item = document.createElement("li"); item.className = "flex items-center justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3"; - const label = document.createElement("div"); - label.className = "min-w-0"; + const info = document.createElement("div"); + info.className = "min-w-0"; const title = document.createElement("p"); title.className = "text-sm font-medium text-gray-100 break-all"; - title.textContent = identifier; + title.textContent = label; - label.appendChild(title); + info.appendChild(title); const actionBtn = document.createElement("button"); actionBtn.type = "button"; actionBtn.className = "px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"; actionBtn.textContent = "Remove"; + actionBtn.dataset.blockedHex = hex; actionBtn.addEventListener("click", () => { - this.showSuccess("Block management coming soon."); + this.handleRemoveBlockedCreator(hex); }); - item.appendChild(label); + item.appendChild(info); item.appendChild(actionBtn); this.profileBlockedList.appendChild(item); }); } + async handleAddBlockedCreator() { + if (!this.profileBlockedInput) { + return; + } + + const rawValue = this.profileBlockedInput.value; + const trimmed = typeof rawValue === "string" ? rawValue.trim() : ""; + + if (!trimmed) { + this.showError("Enter an npub to block."); + return; + } + + if (!this.pubkey) { + this.showError("Please login to manage your block list."); + return; + } + + const actorHex = this.pubkey; + let targetHex = ""; + + if (trimmed.startsWith("npub1")) { + targetHex = this.safeDecodeNpub(trimmed) || ""; + if (!targetHex) { + this.showError("Invalid npub. Please double-check and try again."); + return; + } + } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { + targetHex = trimmed.toLowerCase(); + } else { + this.showError("Enter a valid npub or hex pubkey."); + return; + } + + if (targetHex === actorHex) { + this.showError("You cannot block yourself."); + return; + } + + try { + await userBlocks.ensureLoaded(actorHex); + + if (userBlocks.isBlocked(targetHex)) { + this.showSuccess("You already blocked this creator."); + } else { + await userBlocks.addBlock(targetHex, actorHex); + this.showSuccess( + "Creator blocked. You won't see their videos anymore." + ); + } + + this.profileBlockedInput.value = ""; + this.populateBlockedList(); + await this.loadVideos(); + } catch (error) { + console.error("Failed to add creator to personal block list:", error); + const message = + error?.code === "nip04-missing" + ? "Your Nostr extension must support NIP-04 to manage private lists." + : "Failed to update your block list. Please try again."; + this.showError(message); + } + } + + async handleRemoveBlockedCreator(candidate) { + if (!this.pubkey) { + this.showError("Please login to manage your block list."); + return; + } + + let targetHex = ""; + if (typeof candidate === "string") { + const trimmed = candidate.trim(); + if (!trimmed) { + return; + } + + if (trimmed.startsWith("npub1")) { + targetHex = this.safeDecodeNpub(trimmed) || ""; + } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { + targetHex = trimmed.toLowerCase(); + } + } + + if (!targetHex) { + console.warn("No valid pubkey to remove from block list:", candidate); + return; + } + + try { + await userBlocks.ensureLoaded(this.pubkey); + + if (!userBlocks.isBlocked(targetHex)) { + this.showSuccess("Creator already removed from your block list."); + } else { + await userBlocks.removeBlock(targetHex, this.pubkey); + this.showSuccess("Creator removed from your block list."); + } + + this.populateBlockedList(); + await this.loadVideos(); + } catch (error) { + console.error( + "Failed to remove creator from personal block list:", + error + ); + const message = + error?.code === "nip04-missing" + ? "Your Nostr extension must support NIP-04 to manage private lists." + : "Failed to update your block list. Please try again."; + this.showError(message); + } + } + + isAuthorBlocked(pubkey) { + return userBlocks.isBlocked(pubkey); + } + async refreshAdminPaneState() { const adminNav = this.profileNavButtons.admin; const adminPane = this.profilePaneElements.admin; @@ -5037,6 +5254,14 @@ class bitvidApp { await this.refreshAdminPaneState(); + try { + await userBlocks.loadBlocks(pubkey); + } catch (error) { + console.warn("Failed to load personal block list:", error); + } + + this.populateBlockedList(); + // Hide login button if present if (this.loginButton) { this.loginButton.classList.add("hidden"); @@ -5082,6 +5307,9 @@ class bitvidApp { this.pubkey = null; this.currentUserNpub = null; + userBlocks.reset(); + this.populateBlockedList(); + // Show the login button again if (this.loginButton) { this.loginButton.classList.remove("hidden"); @@ -5573,6 +5801,10 @@ class bitvidApp { return false; } + if (this.isAuthorBlocked(video.pubkey)) { + return false; + } + if (!accessControl.canAccess(video)) { return false; } @@ -6943,7 +7175,62 @@ class bitvidApp { break; } case "block-author": { - this.showSuccess("Block management coming soon."); + if (!this.pubkey) { + this.showError("Please login to manage your block list."); + break; + } + + const authorCandidate = + dataset.author || + (this.currentVideo && this.currentVideo.pubkey) || + ""; + + const trimmed = + typeof authorCandidate === "string" ? authorCandidate.trim() : ""; + if (!trimmed) { + this.showError("Unable to determine the creator to block."); + break; + } + + let normalizedHex = ""; + if (trimmed.startsWith("npub1")) { + normalizedHex = this.safeDecodeNpub(trimmed) || ""; + } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { + normalizedHex = trimmed.toLowerCase(); + } + + if (!normalizedHex) { + this.showError("Unable to determine the creator to block."); + break; + } + + if (normalizedHex === this.pubkey) { + this.showError("You cannot block yourself."); + break; + } + + try { + await userBlocks.ensureLoaded(this.pubkey); + + if (userBlocks.isBlocked(normalizedHex)) { + this.showSuccess("You already blocked this creator."); + } else { + await userBlocks.addBlock(normalizedHex, this.pubkey); + this.showSuccess( + "Creator blocked. You won't see their videos anymore." + ); + } + + this.populateBlockedList(); + await this.loadVideos(); + } catch (error) { + console.error("Failed to update personal block list:", error); + const message = + error?.code === "nip04-missing" + ? "Your Nostr extension must support NIP-04 to manage private lists." + : "Failed to update your block list. Please try again."; + this.showError(message); + } break; } case "report": { @@ -8221,6 +8508,28 @@ class bitvidApp { } } + safeDecodeNpub(npub) { + if (typeof npub !== "string") { + return null; + } + + const trimmed = npub.trim(); + if (!trimmed) { + return null; + } + + try { + const decoded = window.NostrTools.nip19.decode(trimmed); + if (decoded.type === "npub" && typeof decoded.data === "string") { + return decoded.data; + } + } catch (err) { + return null; + } + + return null; + } + /** * Attempts to fetch an older event by its ID if we can't find it in * this.videosMap or from a bulk fetch. Uses nostrClient.getEventById. diff --git a/js/subscriptions.js b/js/subscriptions.js index c79ef25b..e3147a25 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -279,7 +279,22 @@ class SubscriptionsManager { window.app?.dedupeVideosByRoot?.(safeVideos) ?? this.dedupeToNewestByRoot(safeVideos); - if (!dedupedVideos.length) { + const filteredVideos = dedupedVideos.filter((video) => { + if (!video || typeof video !== "object") { + return false; + } + + if ( + window.app?.isAuthorBlocked && + window.app.isAuthorBlocked(video.pubkey) + ) { + return false; + } + + return true; + }); + + if (!filteredVideos.length) { container.innerHTML = `

No videos available yet. @@ -288,14 +303,14 @@ class SubscriptionsManager { } // Sort newest first - dedupedVideos.sort((a, b) => b.created_at - a.created_at); + filteredVideos.sort((a, b) => b.created_at - a.created_at); const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); const fragment = document.createDocumentFragment(); // Only declare localAuthorSet once const localAuthorSet = new Set(); - dedupedVideos.forEach((video, index) => { + filteredVideos.forEach((video, index) => { if (!video.id || !video.title) { console.error("Missing ID or title:", video); return; diff --git a/js/userBlocks.js b/js/userBlocks.js new file mode 100644 index 00000000..2845d203 --- /dev/null +++ b/js/userBlocks.js @@ -0,0 +1,282 @@ +// js/userBlocks.js +import { nostrClient } from "./nostr.js"; + +const BLOCK_LIST_IDENTIFIER = "user-blocks"; + +function normalizeHex(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + + return null; +} + +class UserBlockListManager { + constructor() { + this.blockedPubkeys = new Set(); + this.blockEventId = null; + this.loaded = false; + } + + reset() { + this.blockedPubkeys.clear(); + this.blockEventId = null; + this.loaded = false; + } + + getBlockedPubkeys() { + return Array.from(this.blockedPubkeys); + } + + isBlocked(pubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized) { + return false; + } + return this.blockedPubkeys.has(normalized); + } + + async ensureLoaded(userPubkey) { + if (this.loaded) { + return; + } + + const normalized = normalizeHex(userPubkey); + if (!normalized) { + return; + } + + await this.loadBlocks(normalized); + } + + async loadBlocks(userPubkey) { + const normalized = normalizeHex(userPubkey); + if (!normalized) { + this.reset(); + this.loaded = true; + return; + } + + if (!window?.nostr?.nip04?.decrypt) { + console.warn( + "[UserBlockList] nip04.decrypt is unavailable; treating block list as empty." + ); + this.reset(); + this.loaded = true; + return; + } + + try { + const filter = { + kinds: [30002], + authors: [normalized], + "#d": [BLOCK_LIST_IDENTIFIER], + limit: 1, + }; + + const events = []; + for (const relay of nostrClient.relays) { + try { + const res = await nostrClient.pool.list([relay], [filter]); + if (Array.isArray(res) && res.length) { + events.push(...res); + } + } catch (err) { + console.error(`[UserBlockList] Relay error at ${relay}:`, err); + } + } + + if (!events.length) { + this.blockedPubkeys.clear(); + this.blockEventId = null; + this.loaded = true; + return; + } + + events.sort((a, b) => b.created_at - a.created_at); + const newest = events[0]; + this.blockEventId = newest.id; + + let decrypted = ""; + try { + decrypted = await window.nostr.nip04.decrypt( + normalized, + newest.content + ); + } catch (err) { + console.error("[UserBlockList] Failed to decrypt block list:", err); + this.blockedPubkeys.clear(); + this.loaded = true; + return; + } + + try { + const parsed = JSON.parse(decrypted); + const list = Array.isArray(parsed?.blockedPubkeys) + ? parsed.blockedPubkeys + : []; + const sanitized = list + .map((entry) => normalizeHex(entry)) + .filter((candidate) => { + if (!candidate) { + return false; + } + if (candidate === normalized) { + return false; + } + return true; + }); + this.blockedPubkeys = new Set(sanitized); + } catch (err) { + console.error("[UserBlockList] Failed to parse block list:", err); + this.blockedPubkeys.clear(); + } + } catch (error) { + console.error("[UserBlockList] loadBlocks failed:", error); + this.blockedPubkeys.clear(); + } finally { + this.loaded = true; + } + } + + async addBlock(targetPubkey, userPubkey) { + const actorHex = normalizeHex(userPubkey); + if (!actorHex) { + throw new Error("Invalid user pubkey."); + } + + const targetHex = normalizeHex(targetPubkey); + if (!targetHex) { + const err = new Error("Invalid target pubkey."); + err.code = "invalid"; + throw err; + } + + if (actorHex === targetHex) { + const err = new Error("Cannot block yourself."); + err.code = "self"; + throw err; + } + + await this.ensureLoaded(actorHex); + + if (this.blockedPubkeys.has(targetHex)) { + return { ok: true, already: true }; + } + + const snapshot = new Set(this.blockedPubkeys); + this.blockedPubkeys.add(targetHex); + + try { + await this.publishBlockList(actorHex); + return { ok: true }; + } catch (err) { + this.blockedPubkeys = snapshot; + throw err; + } + } + + async removeBlock(targetPubkey, userPubkey) { + const actorHex = normalizeHex(userPubkey); + if (!actorHex) { + throw new Error("Invalid user pubkey."); + } + + const targetHex = normalizeHex(targetPubkey); + if (!targetHex) { + return { ok: true, already: true }; + } + + await this.ensureLoaded(actorHex); + + if (!this.blockedPubkeys.has(targetHex)) { + return { ok: true, already: true }; + } + + const snapshot = new Set(this.blockedPubkeys); + this.blockedPubkeys.delete(targetHex); + + try { + await this.publishBlockList(actorHex); + return { ok: true }; + } catch (err) { + this.blockedPubkeys = snapshot; + throw err; + } + } + + async publishBlockList(userPubkey) { + if (!window?.nostr?.nip04?.encrypt) { + const err = new Error( + "NIP-04 encryption is required to update the block list." + ); + err.code = "nip04-missing"; + throw err; + } + + if (typeof window.nostr.signEvent !== "function") { + const err = new Error("Nostr extension missing signEvent support."); + err.code = "nip04-missing"; + throw err; + } + + const normalized = normalizeHex(userPubkey); + if (!normalized) { + throw new Error("Invalid user pubkey."); + } + + const payload = { + blockedPubkeys: Array.from(this.blockedPubkeys).filter( + (candidate) => candidate && candidate !== normalized + ), + }; + const plaintext = JSON.stringify(payload); + + let cipherText = ""; + try { + cipherText = await window.nostr.nip04.encrypt(normalized, plaintext); + } catch (error) { + const err = new Error("Failed to encrypt block list."); + err.code = "nip04-missing"; + throw err; + } + + const event = { + kind: 30002, + pubkey: normalized, + created_at: Math.floor(Date.now() / 1000), + tags: [["d", BLOCK_LIST_IDENTIFIER]], + content: cipherText, + }; + + const signedEvent = await window.nostr.signEvent(event); + + await Promise.all( + nostrClient.relays.map(async (relay) => { + try { + await nostrClient.pool.publish([relay], signedEvent); + } catch (err) { + console.error(`[UserBlockList] Failed to publish to ${relay}:`, err); + } + }) + ); + + this.blockEventId = signedEvent.id; + return signedEvent; + } +} + +export const userBlocks = new UserBlockListManager(); + +if (typeof window !== "undefined") { + window.userBlocks = userBlocks; +}