Files
bitvid/js/ui/components/ShareNostrModal.js
T
google-labs-jules[bot]andPR0M3TH3AN f9f23c0d08 [security] replace innerHTML usages in js/ui/components/ShareNostrModal.js — convert to safe DOM APIs
- Replace template `innerHTML` with `DOMParser`
- Replace SVG `innerHTML` with `createElementNS`
- Replace `innerHTML = ""` with `replaceChildren()`
- Update `check-innerhtml` baseline

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-20 20:07:34 +00:00

502 lines
15 KiB
JavaScript

import { createModalAccessibility } from "./modalAccessibility.js";
import { DEFAULT_RELAY_URLS } from "../../nostr/toolkit.js";
import logger from "../../utils/logger.js";
import { relayManager } from "../../relayManager.js";
function normalizeRelayList(relays) {
if (!Array.isArray(relays)) {
return [];
}
const seen = new Set();
return relays
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
.filter((relay) => relay)
.filter((relay) => {
if (seen.has(relay)) {
return false;
}
seen.add(relay);
return true;
});
}
export class ShareNostrModal {
constructor({
removeTrackingScripts,
setGlobalModalState,
eventTarget,
container,
fallbackThumbnailSrc,
onPost,
onCancel,
} = {}) {
this.removeTrackingScripts =
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
this.setGlobalModalState =
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || document.getElementById("modalContainer") || null;
this.fallbackThumbnailSrc =
typeof fallbackThumbnailSrc === "string" ? fallbackThumbnailSrc : "";
this.onPost = typeof onPost === "function" ? onPost : null;
this.onCancel = typeof onCancel === "function" ? onCancel : null;
this.modal = null;
this.overlay = null;
this.modalPanel = null;
this.textarea = null;
this.previewUrl = null;
this.previewThumbnail = null;
this.previewFallback = null;
this.previewTitle = null;
this.previewMeta = null;
this.relayInput = null;
this.relayPills = null;
this.status = null;
this.postButton = null;
this.cancelButton = null;
this.closeButton = null;
this.modalAccessibility = null;
this.activeVideo = null;
this.currentRelays = [];
this.busy = false;
this.bound = false;
this.boundHandlers = {
post: (event) => this.handlePostClick(event),
cancel: (event) => this.handleCancelInteraction(event),
overlay: (event) => this.handleCancelInteraction(event),
relayInput: (event) => this.handleRelayInput(event),
relayPillClick: (event) => this.handleRelayPillClick(event),
};
}
addEventListener(type, listener, options) {
this.eventTarget.addEventListener(type, listener, options);
}
removeEventListener(type, listener, options) {
this.eventTarget.removeEventListener(type, listener, options);
}
dispatch(type, detail) {
this.eventTarget.dispatchEvent(
new CustomEvent(type, {
detail,
})
);
}
async load() {
if (this.modal) {
return this.modal;
}
const targetContainer = this.container || document.getElementById("modalContainer");
if (!targetContainer) {
throw new Error("Modal container element not found!");
}
let modal = targetContainer.querySelector("#shareNostrModal");
if (!modal) {
const response = await fetch("components/share-nostr-modal.html");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
const wrapper = document.createElement("div");
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
wrapper.replaceChildren(...Array.from(doc.body.childNodes));
this.removeTrackingScripts(wrapper);
targetContainer.appendChild(wrapper);
modal = wrapper.querySelector("#shareNostrModal");
}
if (!modal) {
throw new Error("Share Nostr modal markup missing after load.");
}
this.cacheElements(modal);
this.setupModalAccessibility();
this.bindEvents();
this.reset();
return this.modal;
}
cacheElements(modal) {
this.modal = modal;
this.overlay = modal.querySelector(".bv-modal-backdrop") || null;
this.modalPanel = modal.querySelector(".modal-sheet") || modal;
this.textarea = modal.querySelector("#shareNostrContent") || null;
this.previewUrl = modal.querySelector("#shareNostrPreviewUrl") || null;
this.previewThumbnail = modal.querySelector("#shareNostrPreviewThumbnail") || null;
this.previewFallback = modal.querySelector("#shareNostrPreviewFallback") || null;
this.previewTitle = modal.querySelector("#shareNostrPreviewTitle") || null;
this.previewMeta = modal.querySelector("#shareNostrPreviewMeta") || null;
this.relayInput = modal.querySelector("#shareNostrRelays") || null;
this.relayPills = modal.querySelector("#shareNostrRelayPills") || null;
this.status = modal.querySelector("#shareNostrStatus") || null;
this.postButton = modal.querySelector("#postShareNostr") || null;
this.cancelButton = modal.querySelector("#cancelShareNostr") || null;
this.closeButton = modal.querySelector("#closeShareNostrModal") || null;
}
setupModalAccessibility() {
if (!this.modal) {
return;
}
this.modalAccessibility = createModalAccessibility({
root: this.modal,
panel: this.modalPanel || this.modal,
backdrop: this.overlay,
onRequestClose: () => this.handleCancelInteraction(),
});
}
bindEvents() {
if (this.bound || !this.modal) {
return;
}
if (this.postButton) {
this.postButton.addEventListener("click", this.boundHandlers.post);
}
if (this.cancelButton) {
this.cancelButton.addEventListener("click", this.boundHandlers.cancel);
}
if (this.closeButton) {
this.closeButton.addEventListener("click", this.boundHandlers.cancel);
}
if (this.overlay) {
this.overlay.addEventListener("click", this.boundHandlers.overlay);
}
if (this.relayInput) {
this.relayInput.addEventListener("input", this.boundHandlers.relayInput);
}
if (this.relayPills) {
this.relayPills.addEventListener("click", this.boundHandlers.relayPillClick);
}
this.bound = true;
}
reset() {
this.activeVideo = null;
this.currentRelays = [];
this.busy = false;
this.setStatus("");
this.setBusy(false);
if (this.textarea) {
this.textarea.value = "";
}
if (this.previewTitle) {
this.previewTitle.textContent = "--";
}
if (this.previewMeta) {
this.previewMeta.textContent = "--";
}
if (this.previewUrl) {
this.previewUrl.textContent = "--";
this.previewUrl.href = "#";
}
this.setThumbnail("");
this.setRelayList([]);
}
setBusy(isBusy) {
this.busy = isBusy;
const actionButtons = [this.postButton, this.cancelButton, this.closeButton];
actionButtons.forEach((button) => {
if (!(button instanceof HTMLButtonElement)) {
return;
}
button.disabled = isBusy;
});
if (this.postButton) {
this.postButton.setAttribute("aria-busy", isBusy ? "true" : "false");
const label = isBusy ? "Posting..." : "Post";
if (this.postButton.textContent !== label) {
this.postButton.textContent = label;
}
}
}
setStatus(message = "", tone = "muted") {
if (!this.status) {
return;
}
this.status.textContent = message;
const toneClassMap = {
muted: "text-muted",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
};
Object.values(toneClassMap).forEach((cls) => this.status.classList.remove(cls));
const nextClass = toneClassMap[tone] || toneClassMap.muted;
this.status.classList.add(nextClass);
}
resolveDefaultRelays() {
const candidate =
relayManager && typeof relayManager.getWriteRelayUrls === "function"
? relayManager.getWriteRelayUrls()
: [];
const normalized = normalizeRelayList(candidate);
if (normalized.length) {
return normalized;
}
return normalizeRelayList(DEFAULT_RELAY_URLS);
}
parseRelayInput(value) {
if (typeof value !== "string") {
return [];
}
return normalizeRelayList(value.split(","));
}
setRelayList(relays) {
const normalized = normalizeRelayList(relays);
this.currentRelays = normalized;
if (this.relayInput) {
this.relayInput.value = normalized.join(", ");
}
this.renderRelayPills();
}
renderRelayPills() {
if (!this.relayPills) {
return;
}
this.relayPills.replaceChildren();
if (!this.currentRelays.length) {
const placeholder = document.createElement("span");
placeholder.className = "text-xs text-muted";
placeholder.textContent = "No relays selected.";
this.relayPills.appendChild(placeholder);
return;
}
this.currentRelays.forEach((relay) => {
const pill = document.createElement("span");
pill.className =
"inline-flex items-center gap-2 rounded-full border border-border/70 bg-surface-alt px-3 py-1 text-xs text-text";
const label = document.createElement("span");
label.textContent = relay;
label.className = "truncate max-w-[200px]";
pill.appendChild(label);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className =
"btn-ghost text-muted hover:text-text px-1 py-0.5 rounded-full";
removeButton.setAttribute("aria-label", `Remove ${relay}`);
removeButton.dataset.action = "remove-relay";
removeButton.dataset.relay = relay;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "h-3 w-3");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M18 6L6 18M6 6l12 12");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.appendChild(path);
removeButton.appendChild(svg);
pill.appendChild(removeButton);
this.relayPills.appendChild(pill);
});
}
setThumbnail(url) {
const sanitized = typeof url === "string" ? url.trim() : "";
const resolved = sanitized || this.fallbackThumbnailSrc || "";
if (this.previewThumbnail) {
if (resolved) {
this.previewThumbnail.src = resolved;
this.previewThumbnail.classList.remove("hidden");
} else {
this.previewThumbnail.removeAttribute("src");
this.previewThumbnail.classList.add("hidden");
}
}
if (this.previewFallback) {
this.previewFallback.classList.toggle(
"hidden",
Boolean(resolved)
);
}
}
buildShareContent(payload) {
const title =
typeof payload?.title === "string" && payload.title.trim()
? payload.title.trim()
: "Untitled video";
const shareUrl =
typeof payload?.shareUrl === "string" ? payload.shareUrl.trim() : "";
const thumbnail =
typeof payload?.thumbnail === "string" ? payload.thumbnail.trim() : "";
return `${title} — Check out this video on bitvid 👇\n\n${shareUrl}\n\n${thumbnail}`;
}
setVideo(payload) {
this.activeVideo = payload || null;
const title =
typeof payload?.title === "string" && payload.title.trim()
? payload.title.trim()
: "Untitled video";
const shareUrl =
typeof payload?.shareUrl === "string" ? payload.shareUrl.trim() : "";
const thumbnail =
typeof payload?.thumbnail === "string" ? payload.thumbnail.trim() : "";
const pubkey =
typeof payload?.pubkey === "string" && payload.pubkey.trim()
? payload.pubkey.trim()
: "";
const authorName =
typeof payload?.authorName === "string" && payload.authorName.trim()
? payload.authorName.trim()
: "";
if (this.textarea) {
this.textarea.value = this.buildShareContent({
title,
shareUrl,
thumbnail,
});
}
if (this.previewTitle) {
this.previewTitle.textContent = title;
}
if (this.previewMeta) {
const displayAuthor = authorName || pubkey;
this.previewMeta.textContent = displayAuthor
? `Author: ${displayAuthor}`
: "Author info unavailable";
}
if (this.previewUrl) {
const displayUrl = shareUrl || "--";
this.previewUrl.textContent = displayUrl;
this.previewUrl.href = shareUrl || "#";
this.previewUrl.classList.toggle("pointer-events-none", !shareUrl);
}
this.setThumbnail(thumbnail);
}
async open({ video, triggerElement } = {}) {
await this.load();
this.setVideo(video || {});
this.setRelayList(this.resolveDefaultRelays());
this.setStatus("");
this.modal?.classList.remove("hidden");
this.modalAccessibility?.activate({ triggerElement });
this.setGlobalModalState("shareNostr", true);
this.dispatch("share-nostr:open", { video: this.activeVideo });
}
close() {
if (!this.modal) {
return;
}
this.modal.classList.add("hidden");
this.modalAccessibility?.deactivate();
this.setGlobalModalState("shareNostr", false);
this.dispatch("share-nostr:close", { video: this.activeVideo });
this.reset();
}
handleRelayInput(event) {
if (this.busy) {
return;
}
const value = event?.target?.value || "";
this.currentRelays = this.parseRelayInput(value);
this.renderRelayPills();
}
handleRelayPillClick(event) {
const trigger = event?.target?.closest?.("[data-action='remove-relay']");
if (!trigger) {
return;
}
const relay = trigger.dataset.relay;
if (!relay) {
return;
}
this.setRelayList(this.currentRelays.filter((entry) => entry !== relay));
}
async handlePostClick(event) {
if (event?.preventDefault) {
event.preventDefault();
}
if (this.busy) {
return;
}
const content = this.textarea?.value || "";
const relays = this.currentRelays.slice();
const payload = {
video: this.activeVideo,
content,
relays,
};
this.setBusy(true);
this.setStatus("Posting to relays...", "muted");
this.dispatch("share-nostr:post", payload);
if (!this.onPost) {
logger.user.warn("Share on Nostr is not configured yet.");
this.setStatus("Posting is not configured yet.", "warning");
this.setBusy(false);
return;
}
try {
await this.onPost(payload);
this.setStatus("Posted to relays.", "success");
this.close();
} catch (error) {
logger.user.error("Failed to post to relays.");
logger.dev.warn("[ShareNostrModal] Post request failed:", error);
this.setStatus("Failed to post. Please try again.", "danger");
this.setBusy(false);
}
}
handleCancelInteraction(event) {
if (event?.preventDefault) {
event.preventDefault();
}
if (this.busy) {
return;
}
if (this.onCancel) {
this.onCancel({ video: this.activeVideo });
}
this.close();
}
}