mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
All three paths (upload video pick, upload thumbnail pick, edit "Replace file") now route through the shared signer gate — silent kept-unlocked restore, else ONE passphrase prompt — then unlock storage and continue the same pick. Cancel/bad-passphrase abort quietly; a refused thumbnail pick restores the picker. Unlock glue extracted to uploadModalStorageUnlock.js (file-size budget); ensureSigner threaded via initUploadModal/initEditModal. Tests: tests/upload-edit-storage-unlock-gate.test.mjs (12). Co-Authored-By: Claude Fable 5 <[email protected]>
1271 lines
40 KiB
JavaScript
1271 lines
40 KiB
JavaScript
import {
|
|
extractMagnetHints,
|
|
extractAllWebSeeds,
|
|
normalizeWebSeedList,
|
|
setMagnetWebSeeds,
|
|
} from "../../magnetShared.js";
|
|
import { normalizeAndAugmentMagnet } from "../../magnetUtils.js";
|
|
import { createModalAccessibility } from "./modalAccessibility.js";
|
|
import { Nip71FormManager } from "./nip71FormManager.js";
|
|
import { MediaUploader } from "./mediaUploader.js";
|
|
import { initEditModalUpload } from "./editModalUpload.js";
|
|
import { devLogger } from "../../utils/logger.js";
|
|
|
|
export class EditModal {
|
|
constructor({
|
|
removeTrackingScripts,
|
|
setGlobalModalState,
|
|
sanitizers = {},
|
|
escapeHtml,
|
|
showError,
|
|
showSuccess,
|
|
getMode,
|
|
r2Service = null,
|
|
s3Service = null,
|
|
storageService = null,
|
|
getCurrentPubkey = null,
|
|
safeEncodeNpub = null,
|
|
ensureSigner = null,
|
|
eventTarget,
|
|
container,
|
|
} = {}) {
|
|
this.removeTrackingScripts =
|
|
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
|
|
this.setGlobalModalState =
|
|
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
|
|
this.escapeHtml = typeof escapeHtml === "function" ? escapeHtml : (value) => `${value ?? ""}`;
|
|
this.showError = typeof showError === "function" ? showError : () => {};
|
|
this.showSuccess = typeof showSuccess === "function" ? showSuccess : () => {};
|
|
// Storage upload wiring (mirrors UploadModal) so Edit can re-upload files.
|
|
this.storageService = storageService || null;
|
|
this.getCurrentPubkey =
|
|
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
|
|
// Shared signer gate (#56): lets the "Replace file" pickers unlock storage
|
|
// inline (cached keep-unlocked key or one passphrase prompt).
|
|
this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : null;
|
|
this.mediaUploader = new MediaUploader({
|
|
r2Service,
|
|
s3Service,
|
|
storageService,
|
|
getCurrentPubkey: () =>
|
|
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
|
|
safeEncodeNpub:
|
|
typeof safeEncodeNpub === "function" ? safeEncodeNpub : (p) => p,
|
|
});
|
|
this.getMode =
|
|
typeof getMode === "function"
|
|
? getMode
|
|
: () => (this.activeVideo?.mode ?? "live");
|
|
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
|
this.container = container || null;
|
|
|
|
this.sanitizers = {
|
|
text:
|
|
typeof sanitizers.text === "function"
|
|
? sanitizers.text
|
|
: (value) => (typeof value === "string" ? value.trim() : ""),
|
|
url:
|
|
typeof sanitizers.url === "function"
|
|
? sanitizers.url
|
|
: (value) => (typeof value === "string" ? value.trim() : ""),
|
|
magnet:
|
|
typeof sanitizers.magnet === "function"
|
|
? sanitizers.magnet
|
|
: (value) => (typeof value === "string" ? value.trim() : ""),
|
|
checkbox:
|
|
typeof sanitizers.checkbox === "function"
|
|
? sanitizers.checkbox
|
|
: (value) => !!value,
|
|
};
|
|
|
|
this.root = null;
|
|
this.overlay = null;
|
|
this.panel = null;
|
|
this.form = null;
|
|
this.closeButton = null;
|
|
this.cancelButton = null;
|
|
this.submitButton = null;
|
|
this.fieldButtons = [];
|
|
this.fields = {};
|
|
this.visibility = {
|
|
container: null,
|
|
buttons: [],
|
|
};
|
|
this.modalAccessibility = null;
|
|
|
|
this.activeVideo = null;
|
|
this.isVisible = false;
|
|
this.eventsBound = false;
|
|
this.loadPromise = null;
|
|
|
|
this.pendingSubmit = false;
|
|
this.pendingSubmitVideo = null;
|
|
|
|
this.nip71FormManager = new Nip71FormManager();
|
|
this.nip71SectionKey = "edit";
|
|
this.originalNip71Metadata = null;
|
|
this.originalNip71MetadataJson = null;
|
|
// Full stored NIP-71 metadata for the active video. The form now edits only
|
|
// hashtags (the rest of the NIP-71 section was removed for a cleaner UI), so
|
|
// legacy extras (imeta, text tracks, participants, …) must be merged back in
|
|
// on save rather than replaced by the reduced form's empty output.
|
|
this.storedNip71Metadata = null;
|
|
}
|
|
|
|
addEventListener(type, listener, options) {
|
|
this.eventTarget.addEventListener(type, listener, options);
|
|
}
|
|
|
|
removeEventListener(type, listener, options) {
|
|
this.eventTarget.removeEventListener(type, listener, options);
|
|
}
|
|
|
|
emit(type, detail) {
|
|
this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
|
|
}
|
|
|
|
getRoot() {
|
|
return this.root;
|
|
}
|
|
|
|
async load({ container } = {}) {
|
|
if (this.root) {
|
|
return this.root;
|
|
}
|
|
|
|
if (this.loadPromise) {
|
|
return this.loadPromise;
|
|
}
|
|
|
|
this.loadPromise = (async () => {
|
|
const targetContainer =
|
|
container || this.container || document.getElementById("modalContainer");
|
|
if (!targetContainer) {
|
|
throw new Error("Modal container element not found!");
|
|
}
|
|
|
|
let modal = targetContainer.querySelector("#editVideoModal");
|
|
if (!modal) {
|
|
const response = await fetch("components/edit-video-modal.html");
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const html = await response.text();
|
|
const wrapper = document.createElement("div");
|
|
wrapper.innerHTML = html;
|
|
this.removeTrackingScripts(wrapper);
|
|
targetContainer.appendChild(wrapper);
|
|
modal = wrapper.querySelector("#editVideoModal");
|
|
}
|
|
|
|
if (!modal) {
|
|
throw new Error("Edit video modal markup missing after load.");
|
|
}
|
|
|
|
this.container = targetContainer;
|
|
this.root = modal;
|
|
|
|
this.cacheElements(modal);
|
|
this.setupModalAccessibility();
|
|
if (!this.eventsBound) {
|
|
this.bindEvents();
|
|
this.eventsBound = true;
|
|
}
|
|
this.reset();
|
|
|
|
return this.root;
|
|
})();
|
|
|
|
try {
|
|
return await this.loadPromise;
|
|
} finally {
|
|
this.loadPromise = null;
|
|
}
|
|
}
|
|
|
|
cacheElements(context) {
|
|
this.overlay =
|
|
context.querySelector("#editVideoModalOverlay") ||
|
|
context.querySelector(".bv-modal-backdrop") ||
|
|
null;
|
|
this.panel = context.querySelector(".bv-modal__panel") || null;
|
|
this.form = context.querySelector("#editVideoForm") || null;
|
|
this.closeButton = context.querySelector("#closeEditVideoModal") || null;
|
|
this.cancelButton = context.querySelector("#cancelEditVideo") || null;
|
|
this.submitButton = context.querySelector("#submitEditVideo") || null;
|
|
|
|
this.updateSubmitButtonState();
|
|
|
|
this.fieldButtons = Array.from(context.querySelectorAll("[data-edit-target]"));
|
|
|
|
this.fields = {
|
|
title: context.querySelector("#editVideoTitle") || null,
|
|
url: context.querySelector("#editVideoUrl") || null,
|
|
magnet: context.querySelector("#editVideoMagnet") || null,
|
|
ws: context.querySelector("#editVideoWs") || null,
|
|
xs: context.querySelector("#editVideoXs") || null,
|
|
thumbnail: context.querySelector("#editVideoThumbnail") || null,
|
|
description: context.querySelector("#editVideoDescription") || null,
|
|
isPrivate: context.querySelector("#editVideoIsPrivate") || null,
|
|
enableComments: context.querySelector("#editEnableComments") || null,
|
|
isNsfw: context.querySelector("#editVideoIsNsfw") || null,
|
|
isForKids: context.querySelector("#editVideoIsForKids") || null,
|
|
};
|
|
|
|
this.visibility = {
|
|
container: context.querySelector("[data-visibility-toggle]") || null,
|
|
buttons: Array.from(
|
|
context.querySelectorAll("[data-visibility-option]") || []
|
|
),
|
|
helper: context.querySelector("[data-visibility-helper]") || null,
|
|
helperDefault: "",
|
|
};
|
|
|
|
if (this.visibility.helper) {
|
|
this.visibility.helperDefault = this.visibility.helper.textContent || "";
|
|
}
|
|
|
|
const nip71Context = this.form || context;
|
|
this.nip71FormManager.registerSection(this.nip71SectionKey, nip71Context);
|
|
|
|
// Wire optional "Replace file" upload controls (thumbnail + video). All the
|
|
// glue lives in editModalUpload.js to keep this module within budget.
|
|
initEditModalUpload(this, context);
|
|
}
|
|
|
|
setupModalAccessibility() {
|
|
if (!this.root) {
|
|
return;
|
|
}
|
|
|
|
if (this.modalAccessibility?.destroy) {
|
|
this.modalAccessibility.destroy();
|
|
}
|
|
|
|
this.modalAccessibility = createModalAccessibility({
|
|
root: this.root,
|
|
panel: this.panel || this.root,
|
|
backdrop: this.overlay || this.root,
|
|
onRequestClose: () => this.close({ emitCancel: true }),
|
|
});
|
|
}
|
|
|
|
bindEvents() {
|
|
if (this.form) {
|
|
this.form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
this.submit();
|
|
});
|
|
}
|
|
|
|
const cancelHandler = (event) => {
|
|
event?.preventDefault?.();
|
|
this.close({ emitCancel: true });
|
|
};
|
|
|
|
if (this.overlay) {
|
|
this.overlay.addEventListener("click", cancelHandler);
|
|
}
|
|
|
|
if (this.closeButton) {
|
|
this.closeButton.addEventListener("click", cancelHandler);
|
|
}
|
|
|
|
if (this.cancelButton) {
|
|
this.cancelButton.addEventListener("click", cancelHandler);
|
|
}
|
|
|
|
if (Array.isArray(this.fieldButtons)) {
|
|
this.fieldButtons.forEach((button) => {
|
|
if (!button || button.dataset.editListenerAttached === "true") {
|
|
return;
|
|
}
|
|
button.addEventListener("click", (event) => {
|
|
this.handleEditFieldToggle(event);
|
|
});
|
|
button.dataset.editListenerAttached = "true";
|
|
});
|
|
}
|
|
|
|
if (Array.isArray(this.visibility?.buttons)) {
|
|
this.visibility.buttons.forEach((button) => {
|
|
button.addEventListener("click", (event) => {
|
|
const option = event?.currentTarget?.dataset?.visibilityOption;
|
|
if (!option) {
|
|
return;
|
|
}
|
|
this.setVisibility(option);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (this.fields.isPrivate) {
|
|
this.fields.isPrivate.addEventListener("change", () => {
|
|
this.handleIsPrivateChange({ emit: true });
|
|
});
|
|
}
|
|
|
|
if (this.fields.enableComments) {
|
|
this.fields.enableComments.addEventListener("change", (event) => {
|
|
const target = event.currentTarget;
|
|
const value = this.sanitizers.checkbox(target?.checked);
|
|
const detail = {
|
|
field: "enableComments",
|
|
value,
|
|
videoId: this.escapeHtml(this.activeVideo?.id || ""),
|
|
};
|
|
this.emit("video:edit-visibility-change", detail);
|
|
});
|
|
}
|
|
|
|
if (this.fields.isNsfw && this.fields.isForKids) {
|
|
this.setupMutuallyExclusiveCheckboxes(
|
|
this.fields.isNsfw,
|
|
this.fields.isForKids
|
|
);
|
|
}
|
|
|
|
this.nip71FormManager.bindSection(this.nip71SectionKey);
|
|
}
|
|
|
|
setupMutuallyExclusiveCheckboxes(firstInput, secondInput) {
|
|
if (!firstInput || !secondInput) {
|
|
return;
|
|
}
|
|
|
|
const enforceExclusion = (primary, secondary) => {
|
|
if (primary.checked) {
|
|
secondary.checked = false;
|
|
}
|
|
};
|
|
|
|
const handleFirstChange = () => enforceExclusion(firstInput, secondInput);
|
|
const handleSecondChange = () => enforceExclusion(secondInput, firstInput);
|
|
|
|
if (firstInput.checked && secondInput.checked) {
|
|
secondInput.checked = false;
|
|
}
|
|
|
|
firstInput.addEventListener("change", handleFirstChange);
|
|
secondInput.addEventListener("change", handleSecondChange);
|
|
}
|
|
|
|
sanitizeAudienceFlags(flags = {}) {
|
|
const isNsfw = flags?.isNsfw === true;
|
|
const isForKids = flags?.isForKids === true && !isNsfw;
|
|
return { isNsfw, isForKids };
|
|
}
|
|
|
|
normalizeMode(value) {
|
|
if (typeof value === "string") {
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "dev") {
|
|
return "dev";
|
|
}
|
|
}
|
|
return "live";
|
|
}
|
|
|
|
resolveModeForSubmit() {
|
|
let candidate = null;
|
|
if (typeof this.getMode === "function") {
|
|
try {
|
|
candidate = this.getMode({ video: this.activeVideo });
|
|
} catch (_error) {
|
|
candidate = null;
|
|
}
|
|
}
|
|
|
|
if (typeof candidate === "string" && candidate.trim()) {
|
|
return this.normalizeMode(candidate);
|
|
}
|
|
|
|
if (this.activeVideo && typeof this.activeVideo.mode === "string") {
|
|
return this.normalizeMode(this.activeVideo.mode);
|
|
}
|
|
|
|
return "live";
|
|
}
|
|
|
|
reset() {
|
|
if (!this.root) {
|
|
this.activeVideo = null;
|
|
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
|
this.originalNip71Metadata = null;
|
|
this.originalNip71MetadataJson = null;
|
|
this.storedNip71Metadata = null;
|
|
this.setSubmitState({ pending: false });
|
|
return;
|
|
}
|
|
|
|
const inputs = Object.values(this.fields);
|
|
inputs.forEach((input) => {
|
|
if (!input) {
|
|
return;
|
|
}
|
|
if (input.type === "checkbox") {
|
|
const defaultAttr = input.dataset?.defaultChecked;
|
|
const defaultChecked =
|
|
defaultAttr === "true"
|
|
? true
|
|
: defaultAttr === "false"
|
|
? false
|
|
: input.defaultChecked;
|
|
input.checked = defaultChecked;
|
|
input.disabled = false;
|
|
input.removeAttribute("disabled");
|
|
delete input.dataset.isEditing;
|
|
delete input.dataset.state;
|
|
} else {
|
|
input.value = "";
|
|
input.readOnly = false;
|
|
input.removeAttribute("readonly");
|
|
delete input.dataset.isEditing;
|
|
delete input.dataset.state;
|
|
}
|
|
delete input.dataset.originalValue;
|
|
});
|
|
|
|
if (Array.isArray(this.fieldButtons)) {
|
|
this.fieldButtons.forEach((button) => {
|
|
button.classList.add("hidden");
|
|
button.dataset.mode = "locked";
|
|
button.textContent = "Edit field";
|
|
});
|
|
}
|
|
|
|
this.handleIsPrivateChange({ emit: false });
|
|
|
|
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
|
this.originalNip71Metadata = null;
|
|
this.originalNip71MetadataJson = null;
|
|
this.storedNip71Metadata = null;
|
|
this.activeVideo = null;
|
|
this.setSubmitState({ pending: false });
|
|
}
|
|
|
|
async open(video, { triggerElement } = {}) {
|
|
await this.load();
|
|
|
|
if (!video) {
|
|
throw new Error("No video provided for editing.");
|
|
}
|
|
|
|
this.reset();
|
|
|
|
const magnetSource = video.magnet || video.rawMagnet || "";
|
|
const magnetHints = extractMagnetHints(magnetSource);
|
|
// Surface every web seed the magnet carries (one per line), not just the
|
|
// first, so the editor can see and manage backup origins without silently
|
|
// dropping them.
|
|
const magnetWebSeeds = extractAllWebSeeds(magnetSource);
|
|
const effectiveWs = magnetWebSeeds.length
|
|
? magnetWebSeeds.join("\n")
|
|
: video.ws || magnetHints.ws || "";
|
|
const effectiveXs = video.xs || magnetHints.xs || "";
|
|
const enableCommentsValue =
|
|
typeof video.enableComments === "boolean" ? video.enableComments : true;
|
|
const isPrivateValue = video.isPrivate === true;
|
|
const rawIsNsfw = typeof video.isNsfw === "boolean" ? video.isNsfw : false;
|
|
const rawIsForKids =
|
|
typeof video.isForKids === "boolean" ? video.isForKids : false;
|
|
const { isNsfw: isNsfwValue, isForKids: isForKidsValue } =
|
|
this.sanitizeAudienceFlags({
|
|
isNsfw: rawIsNsfw,
|
|
isForKids: rawIsForKids,
|
|
});
|
|
|
|
const editContext = {
|
|
...video,
|
|
ws: effectiveWs,
|
|
xs: effectiveXs,
|
|
enableComments: enableCommentsValue,
|
|
isPrivate: isPrivateValue,
|
|
isNsfw: isNsfwValue,
|
|
isForKids: isForKidsValue,
|
|
};
|
|
|
|
this.applyVideoToForm(editContext);
|
|
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
|
|
|
let nip71Data = video.nip71 && typeof video.nip71 === "object" ? { ...video.nip71 } : {};
|
|
|
|
// Backfill published_at if missing, using the original root creation time
|
|
if (
|
|
!nip71Data.publishedAt &&
|
|
!nip71Data.published_at &&
|
|
!nip71Data["published-at"]
|
|
) {
|
|
const rootCreated = video.rootCreatedAt || video.created_at;
|
|
if (rootCreated) {
|
|
nip71Data.publishedAt = rootCreated;
|
|
}
|
|
}
|
|
|
|
// Format publishedAt for datetime-local input (YYYY-MM-DDThh:mm)
|
|
const rawPublishedAt =
|
|
nip71Data.publishedAt || nip71Data.published_at || nip71Data["published-at"];
|
|
if (rawPublishedAt) {
|
|
const numeric = Number(rawPublishedAt);
|
|
if (Number.isFinite(numeric)) {
|
|
const dt = new Date(numeric * 1000);
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
const formatted = `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(
|
|
dt.getDate()
|
|
)}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
|
nip71Data.publishedAt = formatted;
|
|
}
|
|
}
|
|
|
|
this.nip71FormManager.hydrateSection(this.nip71SectionKey, nip71Data);
|
|
|
|
const initialNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
|
|
this.originalNip71Metadata = this.cloneNip71Metadata(initialNip71);
|
|
// Apply the SAME normalization submit applies to its collected copy, so the
|
|
// nip71Edited JSON comparison is between identically-shaped objects. Without
|
|
// this, submit's publishedAt string→timestamp / delete-empty step made every
|
|
// save look "edited" even when nothing changed.
|
|
this.normalizeCollectedNip71(this.originalNip71Metadata);
|
|
this.originalNip71MetadataJson = JSON.stringify(this.originalNip71Metadata);
|
|
// Keep the video's FULL stored NIP-71 metadata (pre-form): the reduced form
|
|
// only round-trips hashtags, and submit merges them into this so legacy
|
|
// extras survive a save.
|
|
this.storedNip71Metadata =
|
|
video.nip71 && typeof video.nip71 === "object"
|
|
? this.cloneNip71Metadata(video.nip71)
|
|
: null;
|
|
this.activeVideo = editContext;
|
|
|
|
if (this.root) {
|
|
const wasHidden = this.root.classList.contains("hidden");
|
|
if (wasHidden) {
|
|
this.root.classList.remove("hidden");
|
|
this.root.setAttribute("aria-hidden", "false");
|
|
this.setGlobalModalState("editVideo", true);
|
|
const focusTarget =
|
|
this.form?.querySelector("[data-initial-focus]") ||
|
|
this.panel ||
|
|
this.root;
|
|
window.requestAnimationFrame(() => {
|
|
if (typeof focusTarget?.focus === "function") {
|
|
focusTarget.focus();
|
|
}
|
|
});
|
|
}
|
|
this.isVisible = true;
|
|
this.modalAccessibility?.activate({ triggerElement });
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
applyVideoToForm(editContext) {
|
|
if (!editContext) {
|
|
return;
|
|
}
|
|
|
|
const sanitizedFlags = this.sanitizeAudienceFlags({
|
|
isNsfw: editContext.isNsfw,
|
|
isForKids: editContext.isForKids,
|
|
});
|
|
editContext.isNsfw = sanitizedFlags.isNsfw;
|
|
editContext.isForKids = sanitizedFlags.isForKids;
|
|
|
|
const fieldMap = {
|
|
title: editContext.title || "",
|
|
url: editContext.url || "",
|
|
magnet: editContext.magnet || "",
|
|
ws: editContext.ws || "",
|
|
xs: editContext.xs || "",
|
|
thumbnail: editContext.thumbnail || "",
|
|
description: editContext.description || "",
|
|
isPrivate: editContext.isPrivate,
|
|
enableComments: editContext.enableComments,
|
|
isNsfw: editContext.isNsfw,
|
|
isForKids: editContext.isForKids,
|
|
};
|
|
|
|
Object.entries(fieldMap).forEach(([key, rawValue]) => {
|
|
const input = this.fields[key];
|
|
const targetId = this.fieldIdForKey(key);
|
|
const button = this.fieldButtons.find(
|
|
(item) => item?.dataset?.editTarget === targetId
|
|
);
|
|
|
|
if (!input) {
|
|
if (button) {
|
|
button.classList.add("hidden");
|
|
button.dataset.mode = "locked";
|
|
button.textContent = "Edit field";
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (targetId) {
|
|
input.id = targetId;
|
|
}
|
|
|
|
const isCheckbox = input.type === "checkbox";
|
|
if (isCheckbox) {
|
|
const hasValue = rawValue !== undefined;
|
|
const boolValue = rawValue === true;
|
|
input.checked = boolValue;
|
|
input.disabled = hasValue;
|
|
if (hasValue) {
|
|
input.setAttribute("disabled", "disabled");
|
|
} else {
|
|
input.removeAttribute("disabled");
|
|
}
|
|
input.dataset.originalValue = boolValue ? "true" : "false";
|
|
input.dataset.isEditing = hasValue ? "false" : "true";
|
|
if (button) {
|
|
if (hasValue) {
|
|
button.classList.remove("hidden");
|
|
button.dataset.mode = "locked";
|
|
button.textContent = "Edit field";
|
|
} else {
|
|
button.classList.add("hidden");
|
|
button.dataset.mode = "locked";
|
|
button.textContent = "Edit field";
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const value = typeof rawValue === "string" ? rawValue : "";
|
|
const hasValue = value.trim().length > 0;
|
|
|
|
input.value = value;
|
|
input.dataset.originalValue = value;
|
|
input.dataset.isEditing = hasValue ? "false" : "true";
|
|
if (hasValue) {
|
|
input.readOnly = true;
|
|
input.setAttribute("readonly", "readonly");
|
|
input.dataset.state = "locked";
|
|
} else {
|
|
input.readOnly = false;
|
|
input.removeAttribute("readonly");
|
|
delete input.dataset.state;
|
|
}
|
|
|
|
if (button) {
|
|
if (hasValue) {
|
|
button.classList.remove("hidden");
|
|
button.dataset.mode = "locked";
|
|
button.textContent = "Edit field";
|
|
} else {
|
|
button.classList.add("hidden");
|
|
button.dataset.mode = "locked";
|
|
button.textContent = "Edit field";
|
|
}
|
|
}
|
|
});
|
|
|
|
this.handleIsPrivateChange({ emit: false });
|
|
}
|
|
|
|
fieldIdForKey(key) {
|
|
switch (key) {
|
|
case "title":
|
|
return "editVideoTitle";
|
|
case "url":
|
|
return "editVideoUrl";
|
|
case "magnet":
|
|
return "editVideoMagnet";
|
|
case "ws":
|
|
return "editVideoWs";
|
|
case "xs":
|
|
return "editVideoXs";
|
|
case "thumbnail":
|
|
return "editVideoThumbnail";
|
|
case "description":
|
|
return "editVideoDescription";
|
|
case "isPrivate":
|
|
return "editVideoIsPrivate";
|
|
case "enableComments":
|
|
return "editEnableComments";
|
|
case "isNsfw":
|
|
return "editVideoIsNsfw";
|
|
case "isForKids":
|
|
return "editVideoIsForKids";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
close({ emitCancel = false } = {}) {
|
|
if (this.root) {
|
|
this.root.classList.add("hidden");
|
|
this.root.setAttribute("aria-hidden", "true");
|
|
}
|
|
this.isVisible = false;
|
|
this.modalAccessibility?.deactivate();
|
|
this.setGlobalModalState("editVideo", false);
|
|
const cancelledVideo = this.activeVideo;
|
|
this.reset();
|
|
|
|
if (emitCancel) {
|
|
this.emit("video:edit-cancel", {
|
|
videoId: this.escapeHtml(cancelledVideo?.id || ""),
|
|
pubkey: this.escapeHtml(cancelledVideo?.pubkey || ""),
|
|
});
|
|
}
|
|
}
|
|
|
|
handleEditFieldToggle(event) {
|
|
const button = event?.currentTarget;
|
|
if (!button) {
|
|
return;
|
|
}
|
|
|
|
const targetId = button.dataset?.editTarget;
|
|
if (!targetId) {
|
|
return;
|
|
}
|
|
|
|
const input = this.root?.querySelector(`#${targetId}`);
|
|
if (!input) {
|
|
return;
|
|
}
|
|
|
|
const isCheckbox = input.type === "checkbox";
|
|
const isLocked = isCheckbox
|
|
? input.disabled === true
|
|
: input.readOnly === true;
|
|
|
|
if (isLocked) {
|
|
if (isCheckbox) {
|
|
input.disabled = false;
|
|
input.removeAttribute("disabled");
|
|
input.dataset.isEditing = "true";
|
|
delete input.dataset.state;
|
|
} else {
|
|
input.disabled = false;
|
|
input.readOnly = false;
|
|
input.removeAttribute("readonly");
|
|
delete input.dataset.state;
|
|
input.dataset.isEditing = "true";
|
|
}
|
|
button.dataset.mode = "editing";
|
|
button.textContent = "Restore original";
|
|
if (!isCheckbox && typeof input.focus === "function") {
|
|
input.focus();
|
|
if (typeof input.setSelectionRange === "function") {
|
|
const length = input.value.length;
|
|
try {
|
|
input.setSelectionRange(length, length);
|
|
} catch (error) {
|
|
// Ignore selection errors (e.g. unsupported input types)
|
|
}
|
|
}
|
|
}
|
|
if (input === this.fields.isPrivate) {
|
|
this.updateVisibilityToggleUI();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const originalValue = input.dataset?.originalValue || "";
|
|
|
|
if (isCheckbox) {
|
|
input.checked = originalValue === "true";
|
|
input.disabled = true;
|
|
input.setAttribute("disabled", "disabled");
|
|
input.dataset.isEditing = "false";
|
|
button.dataset.mode = "locked";
|
|
button.textContent = "Edit field";
|
|
if (input === this.fields.isPrivate) {
|
|
this.updateVisibilityToggleUI();
|
|
}
|
|
return;
|
|
}
|
|
|
|
input.value = originalValue;
|
|
|
|
if (originalValue) {
|
|
input.readOnly = true;
|
|
input.setAttribute("readonly", "readonly");
|
|
input.dataset.state = "locked";
|
|
input.dataset.isEditing = "false";
|
|
button.dataset.mode = "locked";
|
|
button.textContent = "Edit field";
|
|
} else {
|
|
input.readOnly = false;
|
|
input.removeAttribute("readonly");
|
|
delete input.dataset.state;
|
|
input.dataset.isEditing = "true";
|
|
button.classList.add("hidden");
|
|
button.dataset.mode = "locked";
|
|
button.textContent = "Edit field";
|
|
}
|
|
}
|
|
|
|
setVisibility(option, { emit = true } = {}) {
|
|
const checkbox = this.fields.isPrivate;
|
|
if (!checkbox) {
|
|
return;
|
|
}
|
|
|
|
const normalized = typeof option === "string" ? option.toLowerCase() : "";
|
|
if (normalized !== "public" && normalized !== "private") {
|
|
return;
|
|
}
|
|
|
|
if (checkbox.disabled) {
|
|
this.updateVisibilityToggleUI();
|
|
return;
|
|
}
|
|
|
|
const wantPrivate = normalized === "private";
|
|
if (checkbox.checked === wantPrivate) {
|
|
this.updateVisibilityToggleUI();
|
|
return;
|
|
}
|
|
|
|
checkbox.checked = wantPrivate;
|
|
this.handleIsPrivateChange({ emit });
|
|
}
|
|
|
|
updateVisibilityToggleUI() {
|
|
const checkbox = this.fields.isPrivate;
|
|
const buttons = Array.isArray(this.visibility?.buttons)
|
|
? this.visibility.buttons
|
|
: [];
|
|
if (!checkbox) {
|
|
return;
|
|
}
|
|
|
|
const isPrivate = checkbox.checked === true;
|
|
const disabled = checkbox.disabled === true;
|
|
|
|
buttons.forEach((button) => {
|
|
if (!button) {
|
|
return;
|
|
}
|
|
const option = button.dataset?.visibilityOption || "";
|
|
const normalized = option.toLowerCase();
|
|
const isActive =
|
|
(normalized === "private" && isPrivate) ||
|
|
(normalized === "public" && !isPrivate);
|
|
button.dataset.active = isActive ? "true" : "false";
|
|
button.setAttribute("aria-pressed", isActive ? "true" : "false");
|
|
if (disabled) {
|
|
button.classList.add("is-disabled");
|
|
button.setAttribute("aria-disabled", "true");
|
|
button.tabIndex = -1;
|
|
} else {
|
|
button.classList.remove("is-disabled");
|
|
button.removeAttribute("aria-disabled");
|
|
button.tabIndex = 0;
|
|
}
|
|
});
|
|
|
|
if (this.visibility?.container) {
|
|
this.visibility.container.dataset.state = isPrivate ? "private" : "public";
|
|
if (disabled) {
|
|
this.visibility.container.dataset.disabled = "true";
|
|
} else {
|
|
delete this.visibility.container.dataset.disabled;
|
|
}
|
|
}
|
|
|
|
const helper = this.visibility?.helper;
|
|
if (helper) {
|
|
const privateCopy = this.visibility.helperDefault || helper.textContent;
|
|
const publicCopy =
|
|
"Public notes appear in feeds for everyone who can view your channel.";
|
|
helper.textContent = isPrivate ? privateCopy : publicCopy;
|
|
}
|
|
}
|
|
|
|
handleIsPrivateChange({ emit = true } = {}) {
|
|
const checkbox = this.fields.isPrivate;
|
|
if (!checkbox) {
|
|
return;
|
|
}
|
|
|
|
this.updateVisibilityToggleUI();
|
|
|
|
if (!emit) {
|
|
return;
|
|
}
|
|
|
|
const value = this.sanitizers.checkbox(checkbox.checked);
|
|
const detail = {
|
|
field: "isPrivate",
|
|
value,
|
|
videoId: this.escapeHtml(this.activeVideo?.id || ""),
|
|
};
|
|
this.emit("video:edit-visibility-change", detail);
|
|
}
|
|
|
|
cloneNip71Metadata(metadata) {
|
|
if (metadata == null) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(JSON.stringify(metadata));
|
|
} catch (error) {
|
|
return metadata;
|
|
}
|
|
}
|
|
|
|
// Normalize a collectSection() result in place: convert the datetime-local
|
|
// publishedAt string back to unix seconds and drop it when empty/invalid.
|
|
// Applied to BOTH the open-time original and the submit-time copy so the
|
|
// nip71Edited comparison sees identically-shaped objects.
|
|
normalizeCollectedNip71(metadata) {
|
|
if (!metadata || typeof metadata.publishedAt !== "string") {
|
|
return metadata;
|
|
}
|
|
const dateStr = metadata.publishedAt;
|
|
if (dateStr) {
|
|
const dt = new Date(dateStr);
|
|
if (!isNaN(dt.getTime())) {
|
|
metadata.publishedAt = Math.floor(dt.getTime() / 1000);
|
|
} else {
|
|
delete metadata.publishedAt;
|
|
}
|
|
} else {
|
|
delete metadata.publishedAt;
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
async submit() {
|
|
if (this.pendingSubmit) {
|
|
return;
|
|
}
|
|
|
|
const contextVideo = this.activeVideo || this.pendingSubmitVideo || null;
|
|
|
|
if (!contextVideo || !this.root) {
|
|
if (this.isVisible) {
|
|
this.showError("No video selected for editing.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.setSubmitState({ pending: true, video: contextVideo });
|
|
|
|
const fieldValue = (key) => {
|
|
const input = this.fields[key];
|
|
if (!input || typeof input.value !== "string") {
|
|
return "";
|
|
}
|
|
if (key === "url" || key === "ws" || key === "xs") {
|
|
return this.sanitizers.url(input.value);
|
|
}
|
|
if (key === "magnet") {
|
|
return this.sanitizers.magnet(input.value);
|
|
}
|
|
return this.sanitizers.text(input.value);
|
|
};
|
|
|
|
const original = this.pendingSubmitVideo || this.activeVideo;
|
|
|
|
const titleInput = this.fields.title;
|
|
const urlInput = this.fields.url;
|
|
const magnetInput = this.fields.magnet;
|
|
const wsInput = this.fields.ws;
|
|
const xsInput = this.fields.xs;
|
|
const thumbnailInput = this.fields.thumbnail;
|
|
const descriptionInput = this.fields.description;
|
|
const commentsInput = this.fields.enableComments;
|
|
const privateInput = this.fields.isPrivate;
|
|
const nsfwInput = this.fields.isNsfw;
|
|
const forKidsInput = this.fields.isForKids;
|
|
|
|
const newTitle = fieldValue("title");
|
|
const newUrl = fieldValue("url");
|
|
const newMagnet = fieldValue("magnet");
|
|
const newWs = fieldValue("ws");
|
|
const newXs = fieldValue("xs");
|
|
const newThumbnail = fieldValue("thumbnail");
|
|
const newDescription = fieldValue("description");
|
|
|
|
const isEditing = (input) => {
|
|
if (!input) {
|
|
return true;
|
|
}
|
|
if (input.dataset?.isEditing === "true") {
|
|
return true;
|
|
}
|
|
if (input.type === "checkbox") {
|
|
return input.disabled === false;
|
|
}
|
|
return input.readOnly === false;
|
|
};
|
|
|
|
const titleWasEdited = isEditing(titleInput);
|
|
const urlWasEdited = isEditing(urlInput);
|
|
const magnetWasEdited = isEditing(magnetInput);
|
|
|
|
const finalTitle = titleWasEdited ? newTitle : original.title || "";
|
|
const finalUrl = urlWasEdited ? newUrl : original.url || "";
|
|
const originalMagnetValue =
|
|
typeof original.magnet === "string" ? original.magnet.trim() : "";
|
|
const originalWsValue =
|
|
typeof original.ws === "string" ? original.ws.trim() : "";
|
|
const originalXsValue =
|
|
typeof original.xs === "string" ? original.xs.trim() : "";
|
|
|
|
const wsWasManuallyEdited = wsInput ? wsInput.readOnly === false : false;
|
|
const xsWasManuallyEdited = xsInput ? xsInput.readOnly === false : false;
|
|
|
|
let finalMagnet = magnetWasEdited ? newMagnet : originalMagnetValue;
|
|
let finalWs = wsWasManuallyEdited ? newWs : originalWsValue;
|
|
let finalXs = xsWasManuallyEdited ? newXs : originalXsValue;
|
|
|
|
if (magnetWasEdited) {
|
|
const magnetHintCandidates = extractMagnetHints(finalMagnet);
|
|
if (!wsWasManuallyEdited) {
|
|
finalWs = magnetHintCandidates.ws || "";
|
|
}
|
|
if (!xsWasManuallyEdited) {
|
|
finalXs = magnetHintCandidates.xs || "";
|
|
}
|
|
}
|
|
const finalThumbnail = isEditing(thumbnailInput)
|
|
? newThumbnail
|
|
: original.thumbnail || "";
|
|
const finalDescription = isEditing(descriptionInput)
|
|
? newDescription
|
|
: original.description || "";
|
|
const originalEnableComments =
|
|
typeof original.enableComments === "boolean" ? original.enableComments : true;
|
|
const originalIsPrivate = original.isPrivate === true;
|
|
const originalIsNsfw = original.isNsfw === true;
|
|
const originalIsForKids = original.isForKids === true;
|
|
|
|
const rawNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
|
|
const currentNip71 = this.cloneNip71Metadata(rawNip71);
|
|
this.normalizeCollectedNip71(currentNip71);
|
|
const hasImetaSource = Array.isArray(currentNip71?.imeta)
|
|
? currentNip71.imeta.some((variant) => {
|
|
if (!variant || typeof variant !== "object") {
|
|
return false;
|
|
}
|
|
const hasUrl = typeof variant.url === "string" && variant.url.trim();
|
|
const hasMagnet = typeof variant.x === "string" && variant.x.trim();
|
|
return Boolean(hasUrl || hasMagnet);
|
|
})
|
|
: false;
|
|
|
|
let finalEnableComments = originalEnableComments;
|
|
if (commentsInput) {
|
|
if (commentsInput.disabled) {
|
|
finalEnableComments = commentsInput.dataset.originalValue === "true";
|
|
} else {
|
|
finalEnableComments = this.sanitizers.checkbox(commentsInput.checked);
|
|
}
|
|
}
|
|
|
|
let finalIsPrivate = originalIsPrivate;
|
|
if (privateInput) {
|
|
if (privateInput.disabled) {
|
|
finalIsPrivate = privateInput.dataset.originalValue === "true";
|
|
} else {
|
|
finalIsPrivate = this.sanitizers.checkbox(privateInput.checked);
|
|
}
|
|
}
|
|
|
|
let finalIsNsfw = originalIsNsfw;
|
|
if (nsfwInput) {
|
|
if (nsfwInput.disabled) {
|
|
finalIsNsfw = nsfwInput.dataset.originalValue === "true";
|
|
} else {
|
|
finalIsNsfw = this.sanitizers.checkbox(nsfwInput.checked);
|
|
}
|
|
}
|
|
|
|
let finalIsForKids = originalIsForKids;
|
|
if (forKidsInput) {
|
|
if (forKidsInput.disabled) {
|
|
finalIsForKids = forKidsInput.dataset.originalValue === "true";
|
|
} else {
|
|
finalIsForKids = this.sanitizers.checkbox(forKidsInput.checked);
|
|
}
|
|
}
|
|
|
|
({ isNsfw: finalIsNsfw, isForKids: finalIsForKids } =
|
|
this.sanitizeAudienceFlags({
|
|
isNsfw: finalIsNsfw,
|
|
isForKids: finalIsForKids,
|
|
}));
|
|
|
|
if (!finalTitle || (!finalUrl && !finalMagnet && !hasImetaSource)) {
|
|
this.showError(
|
|
"Title and at least one of URL, Magnet, or a NIP-71 media variant is required."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (finalUrl && !/^https:\/\//i.test(finalUrl)) {
|
|
this.showError("Hosted video URLs must use HTTPS.");
|
|
return;
|
|
}
|
|
|
|
// Auto-derive a webseed torrent when the URL was changed to an external link
|
|
// with no magnet, so an edited external URL keeps the P2P benefit (magnet
|
|
// ws=url). Best-effort + size-capped; on CORS / too-large / any error it stays
|
|
// URL-only. Runs before the magnet normalization below so the derived magnet's
|
|
// ws/xs are extracted the same way a pasted magnet's are. When storage is
|
|
// unlocked we also host the tiny .torrent (xs=) so the webseed can bootstrap
|
|
// P2P — same as the upload modal.
|
|
if (
|
|
urlWasEdited &&
|
|
finalUrl &&
|
|
!finalMagnet &&
|
|
typeof this.mediaUploader?.deriveTorrentForExternalUrl === "function"
|
|
) {
|
|
try {
|
|
let connection = null;
|
|
try {
|
|
connection =
|
|
typeof this.mediaUploader.resolveActiveConnection === "function"
|
|
? await this.mediaUploader.resolveActiveConnection()
|
|
: null;
|
|
} catch (connectionError) {
|
|
connection = null;
|
|
}
|
|
const canHostTorrent = Boolean(
|
|
connection?.unlocked && connection?.credentials && connection?.provider,
|
|
);
|
|
const derived = await this.mediaUploader.deriveTorrentForExternalUrl(
|
|
finalUrl,
|
|
{
|
|
provider: canHostTorrent ? connection.provider : undefined,
|
|
credentials: canHostTorrent ? connection.credentials : undefined,
|
|
},
|
|
);
|
|
if (derived?.magnet) {
|
|
finalMagnet = derived.magnet;
|
|
}
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[EditModal] External URL webseed derivation skipped:",
|
|
error?.message || error,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (finalMagnet) {
|
|
// When the web seed list was manually edited, replace the magnet's full
|
|
// ws= set with exactly what's in the field — this is what makes removal
|
|
// actually stick (append-only normalization would leave deleted seeds
|
|
// behind). Otherwise leave whatever seeds the magnet already carries
|
|
// (including those from a freshly pasted magnet) untouched.
|
|
if (wsWasManuallyEdited) {
|
|
finalMagnet = setMagnetWebSeeds(finalMagnet, normalizeWebSeedList(newWs));
|
|
}
|
|
const result = normalizeAndAugmentMagnet(finalMagnet, {
|
|
webSeed: [],
|
|
xs: finalXs,
|
|
});
|
|
finalMagnet = result.magnet;
|
|
// The content `ws` field stores the primary (first) seed; the magnet
|
|
// carries the complete set.
|
|
finalWs = extractAllWebSeeds(finalMagnet)[0] || "";
|
|
const hints = extractMagnetHints(finalMagnet);
|
|
finalXs = hints.xs;
|
|
} else {
|
|
finalWs = "";
|
|
finalXs = "";
|
|
}
|
|
|
|
const magnetChanged = magnetWasEdited && finalMagnet !== originalMagnetValue;
|
|
const wsEditedFlag = wsWasManuallyEdited || magnetChanged;
|
|
const xsEditedFlag = xsWasManuallyEdited || magnetChanged;
|
|
|
|
const updatedData = {
|
|
version: original.version || 2,
|
|
title: finalTitle,
|
|
magnet: finalMagnet,
|
|
url: finalUrl,
|
|
thumbnail: finalThumbnail,
|
|
description: finalDescription,
|
|
mode: this.resolveModeForSubmit(),
|
|
ws: finalWs,
|
|
xs: finalXs,
|
|
wsEdited: wsEditedFlag,
|
|
xsEdited: xsEditedFlag,
|
|
urlEdited: urlWasEdited,
|
|
magnetEdited: magnetWasEdited,
|
|
enableComments: finalEnableComments,
|
|
isPrivate: finalIsPrivate,
|
|
isNsfw: finalIsNsfw,
|
|
isForKids: finalIsForKids,
|
|
};
|
|
|
|
const currentNip71Json = JSON.stringify(currentNip71);
|
|
const nip71Edited = currentNip71Json !== this.originalNip71MetadataJson;
|
|
// The form now edits ONLY hashtags. Merge them into the video's full stored
|
|
// NIP-71 metadata so legacy extras (imeta, text tracks, participants, …) are
|
|
// preserved on save instead of being replaced by the reduced form's empty
|
|
// fields. (Both sides of the nip71Edited comparison round-trip through the
|
|
// same reduced form, so it still fires exactly on hashtag changes.)
|
|
let finalNip71 = currentNip71;
|
|
if (this.storedNip71Metadata && typeof this.storedNip71Metadata === "object") {
|
|
const preserved = this.cloneNip71Metadata(this.storedNip71Metadata) || {};
|
|
const editedHashtags = Array.isArray(currentNip71?.hashtags)
|
|
? currentNip71.hashtags
|
|
: [];
|
|
preserved.hashtags = editedHashtags;
|
|
if (Array.isArray(preserved.t)) {
|
|
// Some stored payloads mirror hashtags under `t`; keep them in sync.
|
|
preserved.t = editedHashtags;
|
|
}
|
|
finalNip71 = preserved;
|
|
}
|
|
updatedData.nip71 = finalNip71;
|
|
updatedData.nip71Edited = nip71Edited;
|
|
|
|
const originalEvent = {
|
|
id: this.sanitizers.text(original.id || ""),
|
|
pubkey: this.sanitizers.text(original.pubkey || ""),
|
|
videoRootId: this.sanitizers.text(original.videoRootId || ""),
|
|
};
|
|
|
|
this.emit("video:edit-submit", {
|
|
originalEvent,
|
|
updatedData,
|
|
video: { ...original },
|
|
});
|
|
}
|
|
|
|
setSubmitState({ pending = false, video } = {}) {
|
|
const nextPending = Boolean(pending);
|
|
if (nextPending) {
|
|
this.pendingSubmitVideo = video || this.activeVideo || this.pendingSubmitVideo;
|
|
} else {
|
|
this.pendingSubmitVideo = null;
|
|
}
|
|
|
|
this.pendingSubmit = nextPending;
|
|
this.updateSubmitButtonState();
|
|
}
|
|
|
|
updateSubmitButtonState() {
|
|
if (!this.submitButton) {
|
|
return;
|
|
}
|
|
|
|
if (this.pendingSubmit) {
|
|
this.submitButton.disabled = true;
|
|
this.submitButton.setAttribute("disabled", "disabled");
|
|
} else {
|
|
this.submitButton.disabled = false;
|
|
this.submitButton.removeAttribute("disabled");
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (this.modalAccessibility?.destroy) {
|
|
this.modalAccessibility.destroy();
|
|
}
|
|
this.modalAccessibility = null;
|
|
}
|
|
}
|