Merge pull request #143 from PR0M3TH3AN/codex/add-blacklist-button-for-admins

Add blacklist controls to moderator menus
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-09-28 21:29:32 -04:00
commit 511a016db4
6 files changed
+428 -32

No files matched your search

+8
View File
@@ -94,6 +94,14 @@
>
Copy link
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="blacklist-author"
data-context="modal"
data-blacklist-menu="true"
>
Blacklist creator
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="block-author"
+5
View File
@@ -283,6 +283,11 @@ class AccessControl {
return { ok: false, error: "invalid npub" };
}
const normalizedActor = normalizeNpub(actorNpub);
if (normalizedActor && normalized === normalizedActor) {
return { ok: false, error: "self" };
}
if (this.isSuperAdmin(normalized) || this.isAdminEditor(normalized)) {
return { ok: false, error: "immutable" };
}
+5
View File
@@ -448,6 +448,7 @@ async function persistNostrState(actorNpub, updates = {}) {
}
const sanitizedUpdates = {};
const actorNormalized = normalizeNpub(actorNpub);
if (Array.isArray(updates.editors)) {
sanitizedUpdates.editors = sanitizeNpubList(updates.editors).filter((npub) => {
@@ -470,6 +471,10 @@ async function persistNostrState(actorNpub, updates = {}) {
...((sanitizedUpdates.editors || []).map(normalizeNpub) || []),
]);
if (actorNormalized) {
editorGuard.add(actorNormalized);
}
sanitizedUpdates.blacklist = sanitizeNpubList(updates.blacklist).filter(
(npub) => {
const normalized = normalizeNpub(npub);
+138 -1
View File
@@ -4054,6 +4054,20 @@ class bitvidApp {
return this.currentUserNpub;
}
canCurrentUserManageBlacklist() {
const actorNpub = this.getCurrentUserNpub();
if (!actorNpub) {
return false;
}
try {
return accessControl.canEditAdminLists(actorNpub);
} catch (error) {
console.warn("Unable to verify blacklist permissions:", error);
return false;
}
}
ensureAdminActor(requireSuperAdmin = false) {
const actorNpub = this.getCurrentUserNpub();
if (!actorNpub) {
@@ -4290,6 +4304,8 @@ class bitvidApp {
return "Please provide a valid npub address.";
case "immutable":
return "That account cannot be modified.";
case "self":
return "You cannot blacklist yourself.";
case "forbidden":
return "You do not have permission to perform that action.";
case "nostr-unavailable":
@@ -5992,6 +6008,7 @@ class bitvidApp {
this.getShareUrlBase() ||
`${window.location?.origin || ""}${window.location?.pathname || ""}` ||
(window.location?.href ? window.location.href.split(/[?#]/)[0] : "");
const canManageBlacklist = this.canCurrentUserManageBlacklist();
// 3) Build each card
dedupedVideos.forEach((video, index) => {
@@ -6073,6 +6090,13 @@ class bitvidApp {
`
: "";
const blacklistMenuItem = canManageBlacklist
? `
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" data-action="blacklist-author" data-author="${video.pubkey || ""}">
Blacklist creator
</button>`
: "";
const moreMenu = `
<div class="relative inline-block ml-1 overflow-visible" data-more-menu-wrapper="true">
<button
@@ -6098,6 +6122,7 @@ class bitvidApp {
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="copy-link" data-event-id="${video.id || ""}">
Copy link
</button>
${blacklistMenuItem}
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" data-action="block-author" data-author="${video.pubkey || ""}">
Block creator
</button>
@@ -6748,6 +6773,20 @@ class bitvidApp {
}
const action = button.dataset.action || "";
if (action === "blacklist-author") {
const canShow = this.canCurrentUserManageBlacklist();
if (canShow && this.currentVideo && this.currentVideo.pubkey) {
button.dataset.author = this.currentVideo.pubkey;
button.classList.remove("hidden");
button.setAttribute("aria-hidden", "false");
} else {
delete button.dataset.author;
button.classList.add("hidden");
button.setAttribute("aria-hidden", "true");
}
return;
}
if (action === "open-channel" || action === "block-author") {
if (this.currentVideo && this.currentVideo.pubkey) {
button.dataset.author = this.currentVideo.pubkey;
@@ -6766,7 +6805,7 @@ class bitvidApp {
});
}
handleMoreMenuAction(action, dataset = {}) {
async handleMoreMenuAction(action, dataset = {}) {
const normalized = typeof action === "string" ? action.trim() : "";
const context = dataset.context || "";
@@ -6805,6 +6844,104 @@ class bitvidApp {
.catch(() => this.showError("Failed to copy the link."));
break;
}
case "copy-npub": {
const explicitNpub =
typeof dataset.npub === "string" && dataset.npub.trim()
? dataset.npub.trim()
: "";
const authorCandidate = dataset.author || "";
const fallbackNpub = this.safeEncodeNpub(authorCandidate);
const valueToCopy = explicitNpub || fallbackNpub;
if (!valueToCopy) {
this.showError("No npub available to copy.");
break;
}
try {
await navigator.clipboard.writeText(valueToCopy);
this.showSuccess("Channel npub copied to clipboard!");
} catch (error) {
console.error("Failed to copy npub:", error);
this.showError("Failed to copy the npub.");
}
break;
}
case "blacklist-author": {
const actorNpub = this.getCurrentUserNpub();
if (!actorNpub) {
this.showError("Please login as a moderator to manage the blacklist.");
break;
}
try {
await accessControl.ensureReady();
} catch (error) {
console.warn("Failed to refresh moderation state before blacklisting:", error);
}
if (!accessControl.canEditAdminLists(actorNpub)) {
this.showError("Only moderators can manage the blacklist.");
break;
}
let author = dataset.author || "";
if (!author && context === "modal" && this.currentVideo?.pubkey) {
author = this.currentVideo.pubkey;
}
if (!author && this.currentVideo?.pubkey) {
author = this.currentVideo.pubkey;
}
const explicitNpub =
typeof dataset.npub === "string" && dataset.npub.trim()
? dataset.npub.trim()
: "";
const targetNpub = explicitNpub || this.safeEncodeNpub(author);
if (!targetNpub) {
this.showError("Unable to determine the creator npub.");
break;
}
try {
const result = await accessControl.addToBlacklist(
actorNpub,
targetNpub
);
if (result?.ok) {
this.showSuccess("Creator added to the blacklist.");
} else {
const code = result?.error || "unknown";
switch (code) {
case "self":
this.showError("You cannot blacklist yourself.");
break;
case "immutable":
this.showError(
"Moderators cannot blacklist the super admin or fellow moderators."
);
break;
case "invalid npub":
this.showError("Unable to blacklist this creator.");
break;
case "forbidden":
this.showError("Only moderators can manage the blacklist.");
break;
default:
this.showError(
"Failed to update the blacklist. Please try again."
);
break;
}
}
} catch (error) {
console.error("Failed to add creator to blacklist:", error);
this.showError("Failed to update the blacklist. Please try again.");
}
break;
}
case "block-author": {
this.showSuccess("Block management coming soon.");
break;
+173
View File
@@ -14,6 +14,8 @@ let currentChannelHex = null;
let currentChannelNpub = null;
let cachedZapButton = null;
let cachedChannelShareButton = null;
let cachedChannelMenu = null;
function getChannelZapButton() {
if (cachedZapButton && !document.body.contains(cachedZapButton)) {
@@ -42,6 +44,170 @@ function setChannelZapVisibility(visible) {
}
}
function getChannelShareButton() {
if (cachedChannelShareButton && !document.body.contains(cachedChannelShareButton)) {
cachedChannelShareButton = null;
}
if (!cachedChannelShareButton) {
cachedChannelShareButton = document.getElementById("channelShareBtn");
}
return cachedChannelShareButton;
}
function getChannelMenuElement() {
if (cachedChannelMenu && !document.body.contains(cachedChannelMenu)) {
cachedChannelMenu = null;
}
if (!cachedChannelMenu) {
cachedChannelMenu = document.getElementById("moreDropdown-channel-profile");
}
return cachedChannelMenu;
}
function buildChannelShareUrl() {
if (!currentChannelNpub) {
return "";
}
const base = typeof app.getShareUrlBase === "function" ? app.getShareUrlBase() : "";
if (base) {
return `${base}#view=channel-profile&npub=${encodeURIComponent(currentChannelNpub)}`;
}
try {
const url = new URL(window.location.href);
url.search = "";
url.hash = `#view=channel-profile&npub=${currentChannelNpub}`;
return url.toString();
} catch (error) {
console.warn("Falling back to basic channel share URL:", error);
const origin = window.location?.origin || "";
const pathname = window.location?.pathname || "";
if (!origin && !pathname) {
return "";
}
return `${origin}${pathname}#view=channel-profile&npub=${currentChannelNpub}`;
}
}
function syncChannelShareButtonState() {
const shareBtn = getChannelShareButton();
if (!shareBtn) {
return;
}
const hasNpub = typeof currentChannelNpub === "string" && currentChannelNpub;
shareBtn.disabled = !hasNpub;
shareBtn.setAttribute("aria-disabled", (!hasNpub).toString());
shareBtn.classList.toggle("opacity-50", !hasNpub);
shareBtn.classList.toggle("cursor-not-allowed", !hasNpub);
}
function setupChannelShareButton() {
const shareBtn = getChannelShareButton();
if (!shareBtn) {
return;
}
syncChannelShareButtonState();
if (shareBtn.dataset.initialized === "true") {
return;
}
shareBtn.addEventListener("click", () => {
const shareUrl = buildChannelShareUrl();
if (!shareUrl) {
app.showError("Could not generate channel link.");
return;
}
navigator.clipboard
.writeText(shareUrl)
.then(() => app.showSuccess("Channel link copied to clipboard!"))
.catch(() => app.showError("Failed to copy the link."));
});
shareBtn.dataset.initialized = "true";
}
function setupChannelMoreMenu() {
const container = document.querySelector(".channel-profile-container");
if (!container) {
return;
}
app.attachMoreMenuHandlers(container);
}
async function updateChannelMenuState() {
const menu = getChannelMenuElement();
if (!menu) {
return;
}
try {
await accessControl.ensureReady();
} catch (error) {
console.warn("Failed to refresh moderation lists for channel menu:", error);
}
const canBlacklist =
typeof app.canCurrentUserManageBlacklist === "function"
? app.canCurrentUserManageBlacklist()
: false;
const buttons = menu.querySelectorAll("button[data-action]");
buttons.forEach((button) => {
if (!(button instanceof HTMLElement)) {
return;
}
const action = button.dataset.action || "";
if (action === "copy-npub") {
if (currentChannelNpub) {
button.dataset.npub = currentChannelNpub;
button.classList.remove("hidden");
button.setAttribute("aria-hidden", "false");
} else {
delete button.dataset.npub;
button.classList.add("hidden");
button.setAttribute("aria-hidden", "true");
}
return;
}
if (action === "block-author") {
if (currentChannelHex) {
button.dataset.author = currentChannelHex;
button.classList.remove("hidden");
button.setAttribute("aria-hidden", "false");
} else {
delete button.dataset.author;
button.classList.add("hidden");
button.setAttribute("aria-hidden", "true");
}
return;
}
if (action === "blacklist-author") {
if (canBlacklist && currentChannelHex) {
button.dataset.author = currentChannelHex;
if (currentChannelNpub) {
button.dataset.npub = currentChannelNpub;
} else {
delete button.dataset.npub;
}
button.classList.remove("hidden");
button.setAttribute("aria-hidden", "false");
} else {
delete button.dataset.author;
delete button.dataset.npub;
button.classList.add("hidden");
button.setAttribute("aria-hidden", "true");
}
}
});
}
/**
* Initialize the channel profile view.
* Called when #view=channel-profile&npub=...
@@ -77,6 +243,10 @@ export async function initChannelProfileView() {
currentChannelHex = hexPub;
currentChannelNpub = npub;
setupChannelShareButton();
setupChannelMoreMenu();
await updateChannelMenuState();
// 3) If user is logged in, load subscriptions and show sub/unsub button
if (app.pubkey) {
await subscriptions.loadSubscriptions(app.pubkey);
@@ -87,9 +257,12 @@ export async function initChannelProfileView() {
}
setupZapButton();
syncChannelShareButtonState();
// 4) Load user’s profile (banner, avatar, etc.)
await loadUserProfile(hexPub);
await updateChannelMenuState();
syncChannelShareButtonState();
// 5) Load user’s videos (filtered + rendered like the home feed)
await loadUserVideos(hexPub);
+99 -31
View File
@@ -1,29 +1,111 @@
<section class="channel-profile-container">
<!-- Modified banner wrapper for taller, rounded style -->
<div
class="profile-banner relative mb-6 rounded-lg overflow-hidden shadow-lg"
>
<div class="profile-banner relative mb-6 rounded-lg shadow-lg">
<!-- Increase the default height classes -->
<img
id="channelBanner"
class="w-full h-48 md:h-64 object-cover"
class="w-full h-48 md:h-64 object-cover rounded-lg"
src=""
alt="Banner"
/>
<div class="absolute inset-0 bg-black bg-opacity-50"></div>
<div class="absolute inset-0 bg-black bg-opacity-50 rounded-lg"></div>
<div class="absolute bottom-4 left-4 flex items-center">
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
<img
id="channelAvatar"
src="assets/svg/default-profile.svg"
alt="Avatar"
class="w-full h-full object-cover"
/>
<div
class="absolute bottom-4 left-4 right-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
>
<div class="flex items-center">
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
<img
id="channelAvatar"
src="assets/svg/default-profile.svg"
alt="Avatar"
class="w-full h-full object-cover"
/>
</div>
<div class="ml-4 text-white">
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
</div>
</div>
<div class="ml-4 text-white">
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
<div class="flex items-center gap-2 self-start sm:self-end">
<button
id="zapButton"
type="button"
class="icon-button"
aria-label="Send a zap"
>
<img
src="assets/svg/lightning-bolt.svg"
alt="Zap"
class="icon-image"
/>
</button>
<button
id="channelShareBtn"
type="button"
class="icon-button"
aria-label="Share channel"
>
<img
src="assets/svg/share-video.svg"
alt="Share channel"
class="icon-image"
/>
</button>
<div
class="relative inline-block overflow-visible"
data-more-menu-wrapper="true"
>
<button
id="channelMoreBtn"
type="button"
class="icon-button"
aria-haspopup="true"
aria-expanded="false"
aria-label="Channel options"
data-more-dropdown="channel-profile"
>
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5" />
</button>
<div
id="moreDropdown-channel-profile"
class="hidden absolute right-0 top-12 w-48 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
role="menu"
data-more-menu="true"
>
<div class="py-1">
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="copy-npub"
data-context="channel-profile"
>
Copy npub
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="blacklist-author"
data-context="channel-profile"
data-blacklist-menu="true"
>
Blacklist channel
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="block-author"
data-context="channel-profile"
>
Block channel
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="report"
data-context="channel-profile"
>
Report
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -43,21 +125,7 @@
<!-- Area for Subscribe/Unsubscribe button -->
<div class="px-4 my-4">
<div class="flex items-center gap-3">
<button
id="zapButton"
type="button"
class="icon-button"
aria-label="Send a zap"
>
<img
src="assets/svg/lightning-bolt.svg"
alt="Zap"
class="icon-image"
/>
</button>
<div id="subscribeBtnArea" class="hidden"></div>
</div>
<div id="subscribeBtnArea" class="hidden"></div>
</div>
<hr class="my-6" />