Guard user block list from self entries

This commit is contained in:
thePR0M3TH3AN committed 2025-09-28 21:47:56 -04:00
1 parent 511a016db4
commit 62cbc2a2eb
4 files changed
+658 -28

No files matched your search

+24
View File
@@ -177,6 +177,30 @@
role="tabpanel"
aria-labelledby="profileNavBlocked"
>
<p class="text-sm text-gray-400">
Creators you block are hidden from your BitVid experience. This
list is private to your account.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-gray-300" for="blockedInput">
<span class="mb-2 block font-medium">Creator npub</span>
<input
id="blockedInput"
type="text"
inputmode="text"
autocomplete="off"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="npub1..."
/>
</label>
<button
id="addBlockedBtn"
type="button"
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Block creator
</button>
</div>
<div
id="blockedEmpty"
class="rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
+334 -25
View File
@@ -13,6 +13,7 @@ import { trackVideoView } from "./analytics.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js";
import { userBlocks } from "./userBlocks.js";
import {
loadR2Settings,
saveR2Settings,
@@ -513,6 +514,8 @@ class bitvidApp {
this.profileRelayList = null;
this.profileBlockedList = null;
this.profileBlockedEmpty = null;
this.profileBlockedInput = null;
this.profileAddBlockedBtn = null;
this.profileRelayInput = null;
this.profileAddRelayBtn = null;
this.profileRestoreRelaysBtn = null;
@@ -913,7 +916,11 @@ class bitvidApp {
const savedPubKey = localStorage.getItem("userPubKey");
if (savedPubKey) {
// Auto-login if a pubkey was saved
this.login(savedPubKey, false);
try {
await this.login(savedPubKey, false);
} catch (error) {
console.error("Auto-login failed:", error);
}
// If the user was already logged in, show the Subscriptions link
if (this.subscriptionsLink) {
@@ -3299,6 +3306,10 @@ class bitvidApp {
this.profileBlockedList = document.getElementById("blockedList") || null;
this.profileBlockedEmpty =
document.getElementById("blockedEmpty") || null;
this.profileBlockedInput =
document.getElementById("blockedInput") || null;
this.profileAddBlockedBtn =
document.getElementById("addBlockedBtn") || null;
this.profileRelayInput = document.getElementById("relayInput") || null;
this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null;
this.profileRestoreRelaysBtn =
@@ -3388,6 +3399,28 @@ class bitvidApp {
});
}
if (
this.profileAddBlockedBtn &&
this.profileAddBlockedBtn.dataset.bound !== "true"
) {
this.profileAddBlockedBtn.dataset.bound = "true";
this.profileAddBlockedBtn.addEventListener("click", () => {
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.
+18 -3
View File
@@ -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 = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
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;
+282
View File
@@ -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;
}