Files
bitvid/js/ui/components/videoMenuRenderers.js
T
thePR0M3TH3ANandClaude Opus 4.8 f4fe4982bd playlists(#37): "Add to playlist" create/edit flow + enable FEATURE_PLAYLISTS
The create path — makes playlists actually usable end-to-end.
- js/ui/playlistPicker.js: an "Add to playlist" dialog (modeled on confirmDialog)
  listing the signed-in user's playlists as checkboxes (checked when the video is
  already in them) + a "Create new playlist" input. On Save it diffs the
  checkboxes and publishes only changed playlists (add/remove the video) plus any
  new one, via the facade (active signer). Reads the current pubkey from
  nostrClient, so callers pass only the video coordinate.
- videoMenuRenderers.js: "Add to playlist" item in the video ⋯ menu, gated by a
  new canManagePlaylists param.
- moreMenuController.js: computes canManagePlaylists (FEATURE_PLAYLISTS && logged
  in), threads it into the panel context, and adds the add-to-playlist handler
  (resolves the video → coordinate via buildVideoAddressPointer, closes the
  popover, opens the picker; success/error routed to the app's toasts).
- constants.js: FEATURE_PLAYLISTS flipped ON now that the core loop (create /
  add / remove / channel section / playlist view) works. Rollback: set false.

Owner controls on the playlist view (rename / drag-reorder / hard-delete) are a
follow-up; removing every video empties a playlist (drops from the listing).
Syntax + lint + build clean; more-menu-controller tests 9/9.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 13:57:09 -04:00

588 lines
15 KiB
JavaScript

import { normalizeDesignSystemContext } from "../../designSystem.js";
function resolveDocument(documentRef) {
if (documentRef && documentRef.nodeType === 9) {
return documentRef;
}
if (typeof document !== "undefined" && document?.nodeType === 9) {
return document;
}
return null;
}
function createElement(doc, tagName, { classNames = [], attrs = {}, textContent } = {}) {
const el = doc.createElement(tagName);
classNames
.filter((name) => typeof name === "string" && name.trim())
.forEach((name) => el.classList.add(name));
Object.entries(attrs).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") {
return;
}
el.setAttribute(key, value);
});
if (typeof textContent === "string") {
el.textContent = textContent;
}
return el;
}
function normalizePointer(pointerInfo) {
if (!pointerInfo || typeof pointerInfo !== "object") {
return null;
}
const pointer = Array.isArray(pointerInfo.pointer)
? pointerInfo.pointer
: null;
if (!pointer || pointer.length < 2) {
return null;
}
const [type, value, relay] = pointer;
const normalized = {
key: typeof pointerInfo.key === "string" ? pointerInfo.key : "",
type: typeof type === "string" ? type : "",
value: typeof value === "string" ? value : "",
relay: typeof relay === "string" ? relay : "",
};
if (!normalized.type || !normalized.value) {
return null;
}
return normalized;
}
function resolveVideoMetadata(video) {
if (!video || typeof video !== "object") {
return {
id: "",
pubkey: "",
title: "",
description: "",
thumbnail: "",
isPrivate: false,
kind: null,
};
}
const normalized = {
id: typeof video.id === "string" ? video.id : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
title: typeof video.title === "string" ? video.title : "",
description:
typeof video.description === "string" ? video.description : "",
thumbnail:
typeof video.thumbnail === "string" ? video.thumbnail : "",
isPrivate: video.isPrivate === true,
};
if (Number.isFinite(video.kind) && video.kind > 0) {
normalized.kind = Math.floor(video.kind);
} else {
normalized.kind = null;
}
return normalized;
}
function ensureMenuContainer(doc, panelClassNames = []) {
const panel = createElement(doc, "div", {
classNames: ["popover__panel", ...panelClassNames],
attrs: { role: "menu" },
});
const list = createElement(doc, "div", {
classNames: ["menu"],
attrs: { role: "none" },
});
panel.appendChild(list);
return { panel, list };
}
function appendMenuSeparator(doc, list) {
const separator = createElement(doc, "div", {
classNames: ["menu__separator"],
attrs: { role: "separator" },
});
list.appendChild(separator);
return separator;
}
function appendMenuHeading(doc, list, text) {
const heading = createElement(doc, "div", {
classNames: ["menu__heading"],
textContent: text,
});
list.appendChild(heading);
return heading;
}
function appendMenuAction(doc, list, {
text,
action,
dataset = {},
variant = null,
context = "",
}) {
const button = createElement(doc, "button", {
classNames: ["menu__item", "justify-start"],
textContent: text,
attrs: { type: "button", role: "menuitem" },
});
button.dataset.action = action;
if (variant) {
button.dataset.variant = variant;
}
if (context) {
button.dataset.context = context;
}
Object.entries(dataset).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
button.dataset[key] = String(value);
});
list.appendChild(button);
return button;
}
export function createVideoMoreMenuPanel({
document: documentRef = null,
video = null,
pointerInfo = null,
playbackUrl = "",
playbackMagnet = "",
canManageBlacklist = false,
canManagePlaylists = false,
context = "card",
designSystem = null,
} = {}) {
const doc = resolveDocument(documentRef);
if (!doc) {
return null;
}
normalizeDesignSystemContext(designSystem);
const metadata = resolveVideoMetadata(video);
const pointer = normalizePointer(pointerInfo);
const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]);
panel.dataset.menu = "video-more";
panel.dataset.menuContext = context;
appendMenuAction(doc, list, {
text: "Open channel",
action: "open-channel",
dataset: { author: metadata.pubkey, context },
});
appendMenuAction(doc, list, {
text: "Copy link",
action: "copy-link",
dataset: { eventId: metadata.id, context },
});
appendMenuAction(doc, list, {
text: "Popularity",
action: "view-stats",
dataset: { eventId: metadata.id, context },
});
if (canManagePlaylists) {
appendMenuAction(doc, list, {
text: "Add to playlist",
action: "add-to-playlist",
dataset: { eventId: metadata.id, context },
});
}
const baseBoostDataset = {
eventId: metadata.id,
author: metadata.pubkey,
context,
};
if (pointer) {
baseBoostDataset.pointerType = pointer.type;
baseBoostDataset.pointerValue = pointer.value;
if (pointer.relay) {
baseBoostDataset.pointerRelay = pointer.relay;
}
if (pointer.key) {
baseBoostDataset.pointerKey = pointer.key;
}
}
if (Number.isFinite(metadata.kind) && metadata.kind > 0) {
baseBoostDataset.kind = String(metadata.kind);
}
appendMenuHeading(doc, list, "Boost on Nostr…");
appendMenuAction(doc, list, {
text: "Repost (kind 6)",
action: "repost-event",
dataset: baseBoostDataset,
});
if (playbackUrl && metadata.isPrivate !== true) {
appendMenuAction(doc, list, {
text: "Mirror (kind 1063)",
action: "mirror-video",
dataset: {
...baseBoostDataset,
url: playbackUrl,
magnet: playbackMagnet || "",
thumbnail: metadata.thumbnail,
description: metadata.description,
title: metadata.title,
isPrivate: metadata.isPrivate ? "true" : "false",
},
});
}
appendMenuAction(doc, list, {
text: "Rebroadcast",
action: "ensure-presence",
dataset: {
...baseBoostDataset,
pubkey: metadata.pubkey,
},
});
appendMenuSeparator(doc, list);
if (pointer) {
appendMenuAction(doc, list, {
text: "Remove from history",
action: "remove-history",
dataset: {
pointerKey: pointer.key,
pointerType: pointer.type,
pointerValue: pointer.value,
pointerRelay: pointer.relay,
reason: "remove-item",
},
});
}
appendMenuAction(doc, list, {
text: "Mute creator",
action: "mute-author",
dataset: { author: metadata.pubkey, context },
});
appendMenuAction(doc, list, {
text: "Unmute creator",
action: "unmute-author",
dataset: { author: metadata.pubkey, context },
});
if (canManageBlacklist) {
appendMenuAction(doc, list, {
text: "Blacklist creator",
action: "blacklist-author",
variant: "critical",
dataset: { author: metadata.pubkey },
});
appendMenuAction(doc, list, {
text: "Block this video",
action: "blacklist-event",
variant: "critical",
dataset: { eventId: metadata.id },
});
}
appendMenuAction(doc, list, {
text: "Block creator",
action: "block-author",
variant: "critical",
dataset: { author: metadata.pubkey },
});
appendMenuAction(doc, list, {
text: "Report",
action: "report",
dataset: {
eventId: metadata.id,
author: metadata.pubkey,
pointerRelay: pointer && pointer.relay ? pointer.relay : "",
},
});
appendMenuSeparator(doc, list);
appendMenuAction(doc, list, {
text: "Event Details",
action: "event-details",
dataset: {
eventId: metadata.id,
context,
},
});
return panel;
}
export function createVideoShareMenuPanel({
document: documentRef = null,
video = null,
isLoggedIn = false,
hasSigner = false,
hasMagnet = false,
hasCdn = false,
// Optional builder for forced-source test deep links: fn('torrent'|'url') ->
// a `?v=<nevent>&playback=...` URL (or "" when unavailable). When provided,
// the menu offers "Copy WebTorrent URL" / "Copy CDN URL" items that copy a
// link which always plays via that source — handy for testing one path
// consistently. `clipboard` is injectable for tests.
buildForcedSourceUrl = null,
clipboard = null,
designSystem = null,
} = {}) {
const doc = resolveDocument(documentRef);
if (!doc) {
return null;
}
normalizeDesignSystemContext(designSystem);
const metadata = resolveVideoMetadata(video);
const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]);
panel.dataset.menu = "video-share";
appendMenuAction(doc, list, {
text: "Copy URL",
action: "share",
dataset: { eventId: metadata.id },
});
const nostrBtn = appendMenuAction(doc, list, {
text: "Share on Nostr",
action: "share-nostr",
dataset: { eventId: metadata.id },
});
const canShareOnNostr = Boolean(isLoggedIn) && Boolean(hasSigner);
if (!canShareOnNostr) {
nostrBtn.disabled = true;
nostrBtn.classList.add("opacity-50", "cursor-not-allowed");
nostrBtn.title = isLoggedIn
? "Connect a Nostr signer to share"
: "Log in to share on Nostr";
}
const magnetBtn = appendMenuAction(doc, list, {
text: "Copy Magnet",
action: "copy-magnet",
dataset: { eventId: metadata.id },
});
if (!hasMagnet) {
magnetBtn.disabled = true;
magnetBtn.classList.add("opacity-50", "cursor-not-allowed");
magnetBtn.title = "No magnet link available";
}
const cdnBtn = appendMenuAction(doc, list, {
text: "Copy CDN Link",
action: "copy-cdn",
dataset: { eventId: metadata.id },
});
if (!hasCdn) {
cdnBtn.disabled = true;
cdnBtn.classList.add("opacity-50", "cursor-not-allowed");
cdnBtn.title = "No CDN link available";
}
// Forced-source test deep links. Self-contained: they copy a link via the
// injected builder + clipboard rather than routing through the shared menu
// dispatch, so they work regardless of how the share popover is wired.
if (typeof buildForcedSourceUrl === "function") {
const resolveClipboard = () =>
clipboard ||
(typeof navigator !== "undefined" ? navigator.clipboard : null);
const wireForcedCopy = (button, playback, available, unavailableTitle) => {
if (!available) {
button.disabled = true;
button.classList.add("opacity-50", "cursor-not-allowed");
button.title = unavailableTitle;
return;
}
const originalText = button.textContent;
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
const url = buildForcedSourceUrl(playback) || "";
if (!url) {
return;
}
const clip = resolveClipboard();
try {
if (clip && typeof clip.writeText === "function") {
await clip.writeText(url);
}
button.textContent = "Copied!";
} catch (error) {
button.textContent = "Copy failed";
}
if (typeof setTimeout === "function") {
setTimeout(() => {
button.textContent = originalText;
}, 1200);
}
});
};
const torrentLinkBtn = appendMenuAction(doc, list, {
text: "Copy WebTorrent URL",
action: "copy-webtorrent-url",
dataset: { eventId: metadata.id },
});
wireForcedCopy(
torrentLinkBtn,
"torrent",
hasMagnet,
"No WebTorrent source for this video",
);
const cdnLinkBtn = appendMenuAction(doc, list, {
text: "Copy CDN URL",
action: "copy-cdn-url",
dataset: { eventId: metadata.id },
});
wireForcedCopy(
cdnLinkBtn,
"url",
hasCdn,
"No CDN source for this video",
);
}
return panel;
}
export function createChannelProfileMenuPanel({
document: documentRef = null,
context = "channel-profile",
designSystem = null,
} = {}) {
const doc = resolveDocument(documentRef);
if (!doc) {
return null;
}
normalizeDesignSystemContext(designSystem);
const normalizedContext =
typeof context === "string" && context ? context : "channel-profile";
const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]);
panel.dataset.menu = "channel-profile";
panel.dataset.menuContext = normalizedContext;
appendMenuAction(doc, list, {
text: "Copy npub",
action: "copy-npub",
dataset: { context: normalizedContext },
});
appendMenuAction(doc, list, {
text: "Mute channel",
action: "mute-author",
dataset: { context: normalizedContext },
});
appendMenuAction(doc, list, {
text: "Unmute channel",
action: "unmute-author",
dataset: { context: normalizedContext },
});
appendMenuAction(doc, list, {
text: "Blacklist channel",
action: "blacklist-author",
variant: "critical",
dataset: { context: normalizedContext },
});
appendMenuAction(doc, list, {
text: "Block channel",
action: "block-author",
variant: "critical",
dataset: { context: normalizedContext },
});
appendMenuAction(doc, list, {
text: "Report",
action: "report",
dataset: { context: normalizedContext },
});
return panel;
}
export function createVideoSettingsMenuPanel({
document: documentRef = null,
video = null,
index = 0,
capabilities = {},
designSystem = null,
} = {}) {
const doc = resolveDocument(documentRef);
if (!doc) {
return null;
}
normalizeDesignSystemContext(designSystem);
const metadata = resolveVideoMetadata(video);
const normalizedIndex = Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0;
const perms = {
canEdit: capabilities?.canEdit === true,
canRevert: capabilities?.canRevert === true,
canDelete: capabilities?.canDelete === true,
};
const { panel, list } = ensureMenuContainer(doc, ["w-44", "p-0"]);
panel.dataset.menu = "video-settings";
appendMenuAction(doc, list, {
text: "Edit",
action: "edit",
dataset: {
index: String(normalizedIndex),
eventId: metadata.id,
},
});
if (perms.canRevert) {
appendMenuAction(doc, list, {
text: "Revert",
action: "revert",
variant: "critical",
dataset: {
index: String(normalizedIndex),
eventId: metadata.id,
},
});
}
if (perms.canDelete) {
appendMenuAction(doc, list, {
text: "Delete All",
action: "delete",
variant: "critical",
dataset: {
index: String(normalizedIndex),
eventId: metadata.id,
},
});
}
return panel;
}