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;
+}