// components/UploadModal.js import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; import { devLogger, userLogger } from "../../utils/logger.js"; import { getVideoNoteErrorMessage, normalizeVideoNotePayload, } from "../../services/videoNotePayload.js"; import { calculateTorrentInfoHash, createTorrentMetadata, } from "../../utils/torrentHash.js"; import { sanitizeBucketName } from "../../storage/r2-mgmt.js"; import { buildR2Key, buildPublicUrl } from "../../r2.js"; import { buildS3ObjectUrl } from "../../services/s3Service.js"; import { PROVIDERS } from "../../services/storageService.js"; import { buildStoragePointerValue, buildStoragePrefixFromKey, deriveStoragePointerFromUrl, } from "../../utils/storagePointer.js"; import { getActiveSigner, requestDefaultExtensionPermissions, } from "../../nostrClientFacade.js"; import { UI_FEEDBACK_DELAY_MS } from "../../constants.js"; const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; function normalizeInfoHash(value) { return typeof value === "string" ? value.trim().toLowerCase() : ""; } function isValidInfoHash(value) { return INFO_HASH_PATTERN.test(value); } export class UploadModal { constructor({ authService, r2Service, s3Service, storageService, publishVideoNote, removeTrackingScripts, setGlobalModalState, showError, showSuccess, getCurrentPubkey, safeEncodeNpub, eventTarget, container, onRequestStorageSettings, } = {}) { this.authService = authService || null; this.r2Service = r2Service || null; this.s3Service = s3Service || null; this.storageService = storageService || null; this.publishVideoNote = typeof publishVideoNote === "function" ? publishVideoNote : null; this.removeTrackingScripts = typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {}; this.setGlobalModalState = typeof setGlobalModalState === "function" ? setGlobalModalState : () => {}; this.showError = typeof showError === "function" ? showError : () => {}; this.showSuccess = typeof showSuccess === "function" ? showSuccess : () => {}; this.getCurrentPubkey = typeof getCurrentPubkey === "function" ? getCurrentPubkey : null; this.safeEncodeNpub = typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => ""; this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget(); this.container = container || null; this.onRequestStorageSettings = typeof onRequestStorageSettings === "function" ? onRequestStorageSettings : null; this.root = null; this.isVisible = false; this.activeSource = "upload"; // 'upload' | 'external' // Internal state for credentials this.activeCredentials = null; this.activeProvider = null; this.isStorageUnlocked = false; this.storageConfigured = false; // Upload State this.videoUploadState = { status: 'idle', // idle, uploading, complete, error progress: 0, url: '', key: '', file: null, }; this.thumbnailUploadState = { status: 'idle', progress: 0, url: '', key: '', file: null, }; this.torrentState = { status: 'idle', infoHash: '', magnet: '', url: '', // xs (torrent file url) file: null, }; // UI References this.form = null; this.modeButtons = {}; this.sourceSections = {}; this.inputs = {}; this.results = {}; // New results section this.toggles = {}; this.submitButton = null; this.submitStatus = null; this.storageViews = {}; // Logic/State this.nip71FormManager = new Nip71FormManager(); this.r2Unsubscribes = []; this.cleanupHandlers = []; this.modalAccessibility = null; this.loadPromise = null; // Automation this.summaryLocked = true; this.isUploading = false; // Global lock // Upload Session IDs (to guard against zombie callbacks) this.videoUploadId = 0; this.thumbnailUploadId = 0; } // --- Core Lifecycle --- async load({ container } = {}) { if (this.root && this.root.isConnected) 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 not found!"); // Cleanup existing targetContainer.querySelectorAll("#uploadModal").forEach(n => n.remove()); const response = await fetch("components/upload-modal.html"); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const html = await response.text(); const doc = targetContainer.ownerDocument || document; const wrapper = doc.createElement("div"); wrapper.innerHTML = html; this.removeTrackingScripts(wrapper); const modalRoot = wrapper.querySelector("#uploadModal"); if (!modalRoot) throw new Error("UploadModal template must contain #uploadModal element"); targetContainer.appendChild(modalRoot); this.root = modalRoot; this.container = targetContainer; this.cacheElements(); this.bindEvents(); this.setupModalAccessibility(); // this.registerStorageSubscriptions(); // We handle progress locally per file now // Initial State if (this.storageService) { const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false; await this.loadFromStorage(); } this.updateLockUi(); this.setSourceMode("upload"); return this.root; })(); try { return await this.loadPromise; } finally { this.loadPromise = null; } } cacheElements() { const $ = (sel) => this.root.querySelector(sel); this.form = $("#unifiedUploadForm"); this.submitButton = $("#btn-submit"); this.submitStatus = $("#submit-status"); this.closeButton = $("#closeUploadModal"); // Mode Switchers this.modeButtons = { upload: $("#btn-mode-upload"), external: $("#btn-mode-external"), }; // Sections this.sourceSections = { upload: $("#section-source-upload"), external: $("#section-source-external"), settings: $("#section-storage-settings"), advanced: $("#section-advanced"), progress: $("#upload-progress-container"), thumbnailProgress: $("#thumbnail-progress-container"), results: $("#upload-results-container"), }; this.storageViews = { summary: $("#storage-summary-view"), empty: $("#storage-empty-view"), }; // Inputs (Common) this.inputs = { title: $("#input-title"), description: $("#input-description"), thumbnail: $("#input-thumbnail"), thumbnailFile: $("#input-thumbnail-file"), file: $("#input-file"), url: $("#input-url"), magnet: $("#input-magnet"), // Advanced (Manual or NIP71 managed) ws: $("#input-ws"), xs: $("#input-xs"), summary: $("#input-summary"), contentWarning: $("#input-content-warning"), duration: $("#input-duration"), // Progress progress: $("#input-progress"), thumbnailProgress: $("#thumbnail-progress"), }; // Results (Generated Links) this.results = { videoUrl: $("#result-video-url"), magnet: $("#result-magnet"), torrentUrl: $("#result-torrent-url"), }; // Toggles/Buttons this.toggles = { nsfw: $("#check-nsfw"), kids: $("#check-kids"), comments: $("#check-comments"), summaryUnlock: $("#check-summary-unlock"), advanced: $("#btn-advanced-toggle"), storageSettings: $("#btn-storage-settings"), browseThumbnail: $("#btn-thumbnail-file"), storageUnlock: $("#btn-storage-unlock"), manageStorage: $("#btn-manage-storage"), configureStorage: $("#btn-configure-storage"), }; // Status text this.statusText = { storageLock: $("#storage-lock-status"), uploadMain: $("#upload-status-text"), uploadPercent: $("#upload-percent-text"), thumbnailMain: $("#thumbnail-status-text"), thumbnailPercent: $("#thumbnail-percent-text"), summaryProvider: $("#summary-provider"), summaryBucket: $("#summary-bucket"), summaryUrlStyle: $("#summary-url-style"), summaryCopy: $("#summary-copy"), }; this.nip71FormManager.registerSection("main", this.form); } bindEvents() { // Mode Switching this.modeButtons.upload.addEventListener("click", () => this.setSourceMode("upload")); this.modeButtons.external.addEventListener("click", () => this.setSourceMode("external")); // Toggles this.setupAccordion(this.toggles.advanced, this.sourceSections.advanced); this.setupAccordion(this.toggles.storageSettings, this.sourceSections.settings); // Form Submission this.form.addEventListener("submit", (e) => { e.preventDefault(); this.handleSubmit(); }); // Storage Unlock if (this.toggles.storageUnlock) { this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock()); } // Manage Storage if (this.toggles.manageStorage) { this.toggles.manageStorage.addEventListener("click", () => { if (this.onRequestStorageSettings) { this.close(); this.onRequestStorageSettings(); } }); } if (this.toggles.configureStorage) { this.toggles.configureStorage.addEventListener("click", (e) => { e.preventDefault(); if (this.onRequestStorageSettings) { this.close(); this.onRequestStorageSettings(); } }); } // Automation this.setupDescriptionMirror(); this.setupMutuallyExclusiveCheckboxes(this.toggles.nsfw, this.toggles.kids); this.setupNsfwToContentWarning(); this.setupThumbnailInput(); // File Selection (Triggers upload immediately) if (this.inputs.file) { this.inputs.file.addEventListener("change", (e) => this.handleVideoSelection(e)); } // Close this.closeButton.addEventListener("click", () => this.close()); // NIP-71 Manager this.nip71FormManager.bindSection("main"); } // --- Logic & State --- setSourceMode(mode) { this.activeSource = mode; // UI Updates const isUpload = mode === "upload"; this.modeButtons.upload.setAttribute("aria-pressed", isUpload); this.modeButtons.upload.classList.toggle("bg-surface", isUpload); this.modeButtons.upload.classList.toggle("text-text", isUpload); this.modeButtons.upload.classList.toggle("shadow-sm", isUpload); this.modeButtons.upload.classList.toggle("text-muted", !isUpload); this.modeButtons.external.setAttribute("aria-pressed", !isUpload); this.modeButtons.external.classList.toggle("bg-surface", !isUpload); this.modeButtons.external.classList.toggle("text-text", !isUpload); this.modeButtons.external.classList.toggle("shadow-sm", !isUpload); this.modeButtons.external.classList.toggle("text-muted", isUpload); // Sections if (isUpload) { this.sourceSections.upload.classList.remove("hidden"); this.sourceSections.external.classList.add("hidden"); // Show results if we have them if (this.videoUploadState.url || this.torrentState.magnet) { this.sourceSections.results.classList.remove("hidden"); } if (this.toggles.browseThumbnail) { this.toggles.browseThumbnail.classList.remove("hidden"); } } else { this.sourceSections.upload.classList.add("hidden"); this.sourceSections.external.classList.remove("hidden"); this.sourceSections.results.classList.add("hidden"); if (this.toggles.browseThumbnail) { this.toggles.browseThumbnail.classList.add("hidden"); } } this.submitButton.textContent = isUpload ? "Publish Video" : "Publish Video"; } setupAccordion(btn, section) { if (!btn || !section) return; btn.addEventListener("click", () => { const isHidden = section.classList.contains("hidden"); if (isHidden) { section.classList.remove("hidden"); btn.setAttribute("aria-expanded", "true"); const icon = btn.querySelector("svg"); if (icon) icon.classList.add("rotate-90"); } else { section.classList.add("hidden"); btn.setAttribute("aria-expanded", "false"); const icon = btn.querySelector("svg"); if (icon) icon.classList.remove("rotate-90"); } }); } // --- Automation Helpers --- setupDescriptionMirror() { const { description, summary } = this.inputs; const { summaryUnlock } = this.toggles; if (!description || !summary) return; description.addEventListener("input", () => { if (this.summaryLocked) summary.value = description.value; }); if (summaryUnlock) { summaryUnlock.addEventListener("change", () => { this.summaryLocked = !summaryUnlock.checked; summary.readOnly = this.summaryLocked; if (this.summaryLocked) { summary.value = description.value; summary.classList.add("text-muted"); } else { summary.classList.remove("text-muted"); } }); } } setupMutuallyExclusiveCheckboxes(a, b) { if (!a || !b) return; a.addEventListener("change", () => { if (a.checked) b.checked = false; }); b.addEventListener("change", () => { if (b.checked) a.checked = false; }); } setupNsfwToContentWarning() { const { nsfw } = this.toggles; const { contentWarning } = this.inputs; if (!nsfw || !contentWarning) return; nsfw.addEventListener("change", () => { if (nsfw.checked && !contentWarning.value) { contentWarning.value = "NSFW"; } else if (!nsfw.checked && contentWarning.value === "NSFW") { contentWarning.value = ""; } }); } setupThumbnailInput() { const { thumbnailFile, thumbnail } = this.inputs; const { browseThumbnail } = this.toggles; if (!thumbnailFile || !browseThumbnail) return; browseThumbnail.addEventListener("click", () => thumbnailFile.click()); thumbnailFile.addEventListener("change", () => { const file = thumbnailFile.files?.[0]; if (file) { thumbnail.value = ""; thumbnail.placeholder = `Selected: ${file.name}`; thumbnail.disabled = true; this.handleThumbnailSelection(file); // Trigger upload immediately } else { thumbnail.placeholder = "https://example.com/thumbnail.jpg"; thumbnail.disabled = false; } }); } // --- Immediate Upload Handlers --- async handleVideoSelection(e) { const file = e.target.files?.[0]; if (!file) return; if (!this.storageConfigured) { alert("Please configure storage before selecting a file."); e.target.value = ""; // Clear selection return; } if (!this.isStorageUnlocked) { alert("Please unlock storage before selecting a file."); e.target.value = ""; return; } // Start new session const currentUploadId = ++this.videoUploadId; // Reset UI classes if (this.statusText.uploadMain) { this.statusText.uploadMain.classList.remove("text-critical"); this.statusText.uploadMain.classList.add("text-text"); } // Reset Video State this.videoUploadState = { status: 'uploading', progress: 0, url: '', key: '', file: file, }; // Reset Torrent State this.torrentState = { status: 'pending', infoHash: '', magnet: '', url: '', file: null, }; // Show UI this.sourceSections.progress.classList.remove("hidden"); this.sourceSections.results.classList.remove("hidden"); if (this.results.videoUrl) this.results.videoUrl.value = "Uploading..."; if (this.results.magnet) this.results.magnet.value = "Pending..."; if (this.results.torrentUrl) this.results.torrentUrl.value = "Pending..."; this.updateVideoProgress(0, "Preparing upload..."); try { // 1. Compute Identifier (Info Hash / Fingerprint) before generating keys const identifier = await this.resolveUploadIdentifier(file); if (this.videoUploadId !== currentUploadId) return; // Zombie guard // 2. Prepare Upload (Get Creds & Bucket) const pubkey = this.getCurrentPubkey(); const npub = this.safeEncodeNpub(pubkey); const service = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2" ? this.r2Service : this.s3Service; const { settings, bucketEntry } = await service.prepareUpload(npub, { credentials: this.activeCredentials }); if (this.videoUploadId !== currentUploadId) return; // Zombie guard // 3. Determine Keys const videoKey = buildR2Key(npub, file, identifier); const baseDomain = bucketEntry.publicBaseUrl; // Note: buildPublicUrl works for both if the base is clean // For S3, buildS3ObjectUrl might be safer if we have complex paths, but prepareUpload standardizes on publicBaseUrl let videoPublicUrl = ""; if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") { videoPublicUrl = buildPublicUrl(baseDomain, videoKey); } else { videoPublicUrl = buildS3ObjectUrl({ publicBaseUrl: baseDomain, key: videoKey, // For S3 we might need forcePathStyle if publicBaseUrl isn't set, but prepareUpload enforces it. }); } // 3. Start Video Upload this.updateVideoProgress(0, "Uploading video..."); devLogger.debug("UploadModal uploadFile settings presence", { hasAccountId: Boolean(settings.accountId), hasEndpoint: Boolean(settings.endpoint), hasProvider: Boolean(settings.provider), hasRegion: Boolean(settings.region), hasAccessKeyId: Boolean(settings.accessKeyId), hasSecretAccessKey: Boolean(settings.secretAccessKey), hasForcePathStyle: settings.forcePathStyle !== undefined, }); const uploadPromise = service.uploadFile({ file, bucket: bucketEntry.bucket, key: videoKey, // Spread settings carefully accountId: settings.accountId, endpoint: settings.endpoint, provider: settings.provider || this.activeProvider || "cloudflare_r2", region: settings.region, accessKeyId: settings.accessKeyId, secretAccessKey: settings.secretAccessKey, forcePathStyle: settings.forcePathStyle, createBucketIfMissing: true, onProgress: (fraction) => { if (this.videoUploadId === currentUploadId) { this.updateVideoProgress(fraction); } } }); // 4. Calculate Info Hash (Parallel if possible, but JS single thread limits this. // Since createTorrentMetadata reads the file, it might contend with upload read. // Let's do it concurrently and hope the browser handles file I/O well. this.updateVideoProgress(null, "Uploading & Calculating Hash..."); const torrentPromise = this.generateTorrentMetadata({ file, videoPublicUrl }); const [uploadResult, torrentResult] = await Promise.all([uploadPromise, torrentPromise]); if (this.videoUploadId !== currentUploadId) return; // Zombie guard // Video Complete this.videoUploadState.status = 'complete'; this.videoUploadState.url = videoPublicUrl; this.videoUploadState.key = videoKey; const storagePrefix = buildStoragePrefixFromKey({ publicBaseUrl: baseDomain, key: videoKey, }); const providerLabel = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2" ? "r2" : "s3"; this.videoUploadState.storagePointer = buildStoragePointerValue({ provider: providerLabel, prefix: storagePrefix, }); if (this.results.videoUrl) this.results.videoUrl.value = videoPublicUrl; this.updateVideoProgress(1, "Video uploaded."); // 5. Handle Torrent Result & Upload .torrent file if (torrentResult.hasValidInfoHash && torrentResult.torrentFile) { const baseKey = videoKey.replace(/\.[^/.]+$/, ""); const torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`; let torrentPublicUrl = ""; if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") { torrentPublicUrl = buildPublicUrl(baseDomain, torrentKey); } else { torrentPublicUrl = buildS3ObjectUrl({ publicBaseUrl: baseDomain, key: torrentKey }); } this.updateVideoProgress(1, "Uploading torrent metadata..."); devLogger.debug("UploadModal uploadFile settings presence", { hasAccountId: Boolean(settings.accountId), hasEndpoint: Boolean(settings.endpoint), hasProvider: Boolean(settings.provider), hasRegion: Boolean(settings.region), hasAccessKeyId: Boolean(settings.accessKeyId), hasSecretAccessKey: Boolean(settings.secretAccessKey), hasForcePathStyle: settings.forcePathStyle !== undefined, }); await service.uploadFile({ file: torrentResult.torrentFile, bucket: bucketEntry.bucket, key: torrentKey, accountId: settings.accountId, endpoint: settings.endpoint, provider: settings.provider || this.activeProvider || "cloudflare_r2", region: settings.region, accessKeyId: settings.accessKeyId, secretAccessKey: settings.secretAccessKey, forcePathStyle: settings.forcePathStyle, createBucketIfMissing: true, }); if (this.videoUploadId !== currentUploadId) return; // Zombie guard this.torrentState.status = 'complete'; this.torrentState.infoHash = torrentResult.infoHash; this.torrentState.url = torrentPublicUrl; this.torrentState.file = torrentResult.torrentFile; // Construct Magnet const encodedDn = encodeURIComponent(file.name); const encodedWs = encodeURIComponent(videoPublicUrl); const encodedXs = encodeURIComponent(torrentPublicUrl); const magnet = `magnet:?xt=urn:btih:${torrentResult.infoHash}&dn=${encodedDn}&ws=${encodedWs}&xs=${encodedXs}`; this.torrentState.magnet = magnet; if (this.results.magnet) this.results.magnet.value = magnet; if (this.results.torrentUrl) this.results.torrentUrl.value = torrentPublicUrl; this.updateVideoProgress(1, "Ready to publish!"); } else { this.torrentState.status = 'skipped'; // Failed hash or invalid this.updateVideoProgress(1, "Upload complete (No torrent fallback)."); if (this.results.magnet) this.results.magnet.value = "Not available (Info Hash failed)"; if (this.results.torrentUrl) this.results.torrentUrl.value = "Not available"; } } catch (err) { if (this.videoUploadId !== currentUploadId) return; // Zombie guard userLogger.error("Video upload sequence failed:", err); this.videoUploadState.status = 'error'; this.updateVideoProgress(null, "Upload failed."); if (this.statusText.uploadMain) { this.statusText.uploadMain.classList.remove("text-text"); this.statusText.uploadMain.classList.add("text-critical"); } if (this.results.videoUrl) this.results.videoUrl.value = "Upload Failed"; if (this.results.magnet) this.results.magnet.value = "Upload Failed"; if (this.results.torrentUrl) this.results.torrentUrl.value = "Upload Failed"; alert(`Upload failed: ${err.message}`); if (this.inputs.file) this.inputs.file.value = ""; // Reset } } async handleThumbnailSelection(file) { if (!this.storageConfigured || !this.isStorageUnlocked) { // We can't upload yet. Just hold it in state or warn? // Since UI disables the browse button until unlocked (mostly), we assume safe. // But if they just unlocked, we're good. return; } const currentUploadId = ++this.thumbnailUploadId; this.thumbnailUploadState = { status: 'uploading', progress: 0, url: '', key: '', file: file, }; this.sourceSections.thumbnailProgress.classList.remove("hidden"); this.updateThumbnailProgress(0, "Uploading thumbnail..."); try { const pubkey = this.getCurrentPubkey(); const npub = this.safeEncodeNpub(pubkey); const service = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2" ? this.r2Service : this.s3Service; const { settings, bucketEntry } = await service.prepareUpload(npub, { credentials: this.activeCredentials }); if (this.thumbnailUploadId !== currentUploadId) return; // Derive Key (randomish or based on file) // We don't have the video key here easily if video isn't selected yet. // Use a standalone key structure: npub/thumbnails/timestamp-name const timestamp = Date.now(); const cleanName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_"); const key = `${npub}/thumbnails/${timestamp}-${cleanName}`; const baseDomain = bucketEntry.publicBaseUrl; let publicUrl = ""; if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") { publicUrl = buildPublicUrl(baseDomain, key); } else { publicUrl = buildS3ObjectUrl({ publicBaseUrl: baseDomain, key }); } devLogger.debug("UploadModal uploadFile settings presence", { hasAccountId: Boolean(settings.accountId), hasEndpoint: Boolean(settings.endpoint), hasProvider: Boolean(settings.provider), hasRegion: Boolean(settings.region), hasAccessKeyId: Boolean(settings.accessKeyId), hasSecretAccessKey: Boolean(settings.secretAccessKey), hasForcePathStyle: settings.forcePathStyle !== undefined, }); await service.uploadFile({ file, bucket: bucketEntry.bucket, key, accountId: settings.accountId, endpoint: settings.endpoint, provider: settings.provider || this.activeProvider || "cloudflare_r2", region: settings.region, accessKeyId: settings.accessKeyId, secretAccessKey: settings.secretAccessKey, forcePathStyle: settings.forcePathStyle, createBucketIfMissing: true, onProgress: (fraction) => { if (this.thumbnailUploadId === currentUploadId) { this.updateThumbnailProgress(fraction); } } }); if (this.thumbnailUploadId !== currentUploadId) return; this.thumbnailUploadState.status = 'complete'; this.thumbnailUploadState.url = publicUrl; if (this.inputs.thumbnail) this.inputs.thumbnail.value = publicUrl; this.updateThumbnailProgress(1, "Thumbnail uploaded."); // Hide progress after a delay setTimeout(() => { if (this.thumbnailUploadId === currentUploadId) { this.sourceSections.thumbnailProgress.classList.add("hidden"); } }, UI_FEEDBACK_DELAY_MS); } catch (err) { if (this.thumbnailUploadId !== currentUploadId) return; userLogger.error("Thumbnail upload failed:", err); this.thumbnailUploadState.status = 'error'; this.updateThumbnailProgress(null, "Failed."); alert("Thumbnail upload failed."); } } updateVideoProgress(fraction, text) { if (text && this.statusText?.uploadMain) { this.statusText.uploadMain.textContent = text; } if (fraction === null) { // Indeterminate or error return; } const pct = Math.round(fraction * 100); if (this.inputs.progress) this.inputs.progress.value = pct; if (this.statusText.uploadPercent) this.statusText.uploadPercent.textContent = `${pct}%`; } updateThumbnailProgress(fraction, text) { if (text && this.statusText.thumbnailMain) this.statusText.thumbnailMain.textContent = text; if (fraction === null) return; const pct = Math.round(fraction * 100); if (this.inputs.thumbnailProgress) this.inputs.thumbnailProgress.value = pct; if (this.statusText.thumbnailPercent) this.statusText.thumbnailPercent.textContent = `${pct}%`; } // --- R2/Storage Integration (View Only now) --- async refreshState() { if (this.storageService) { const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false; this.updateLockUi(); await this.loadFromStorage(); } } async loadFromStorage() { if (!this.storageService) return; const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; if (!pubkey) { this.activeCredentials = null; this.activeProvider = null; this.storageConfigured = false; this.toggleStorageView("empty"); return; } try { const connections = await this.storageService.listConnections(pubkey); // Prefer default connection const defaultConn = connections.find(c => c.meta?.defaultForUploads); const targetConn = defaultConn || connections[0]; if (targetConn) { this.storageConfigured = true; this.activeProvider = targetConn.provider; // UI Updates this.toggleStorageView("summary"); const providerName = this.getProviderLabel(targetConn.provider || targetConn.meta?.provider); const bucketName = targetConn.meta?.bucket || "Unknown Bucket"; const urlStyle = this.describeUrlStyle(targetConn.provider, targetConn.meta?.forcePathStyle); if (this.statusText.summaryProvider) { this.statusText.summaryProvider.textContent = providerName; this.statusText.summaryProvider.title = providerName; } if (this.statusText.summaryBucket) { this.statusText.summaryBucket.textContent = bucketName; this.statusText.summaryBucket.title = bucketName; } if (this.statusText.summaryUrlStyle) { this.statusText.summaryUrlStyle.textContent = urlStyle; this.statusText.summaryUrlStyle.title = urlStyle; } if (this.statusText.summaryCopy) this.statusText.summaryCopy.textContent = this.getSummaryCopy(targetConn.provider); if (this.isStorageUnlocked) { const details = await this.storageService.getConnection(pubkey, targetConn.id); if (details) { this.activeCredentials = details; } else { this.activeCredentials = null; // Should not happen if unlocked } } else { this.activeCredentials = null; // Locked } } else { // No connections configured this.storageConfigured = false; this.activeCredentials = null; this.activeProvider = null; this.toggleStorageView("empty"); } this.updateLockUi(); } catch (err) { userLogger.error("Failed to load connection", err); this.toggleStorageView("empty"); this.activeCredentials = null; this.storageConfigured = false; this.updateLockUi(); } } toggleStorageView(viewName) { if (!this.storageViews?.summary || !this.storageViews?.empty) { return; } if (viewName === "summary") { this.storageViews.summary.classList.remove("hidden"); this.storageViews.empty.classList.add("hidden"); } else { this.storageViews.summary.classList.add("hidden"); this.storageViews.empty.classList.remove("hidden"); } } async handleUnlock() { if (!this.storageService) return; const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; if (!pubkey) return; // We need a signer. Try active signer or authService let signer = getActiveSigner(); if (!signer && this.authService?.signer) { signer = this.authService.signer; } const canSign = typeof signer?.canSign === "function" ? signer.canSign() : typeof signer?.signEvent === "function"; if (!canSign) { alert("No signer available to unlock storage."); return; } try { if (signer?.type === "extension" || signer?.type === "nip07") { const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult?.ok) { alert("Extension permissions are required to unlock storage."); return; } } if (this.toggles.storageUnlock) { this.toggles.storageUnlock.textContent = "Unlocking..."; this.toggles.storageUnlock.disabled = true; } await this.storageService.unlock(pubkey, { signer }); this.isStorageUnlocked = true; this.updateLockUi(); await this.loadFromStorage(); } catch (err) { userLogger.error("Unlock failed", err); alert("Failed to unlock storage: " + err.message); } finally { if (this.toggles.storageUnlock) { this.toggles.storageUnlock.textContent = "Unlock"; this.toggles.storageUnlock.disabled = false; } } } updateLockUi() { const locked = !this.isStorageUnlocked; if (this.statusText?.storageLock) { this.statusText.storageLock.textContent = locked ? "Locked 🔒" : "Unlocked 🔓"; this.statusText.storageLock.className = locked ? "text-xs text-critical" : "text-xs text-success"; } if (this.toggles?.storageUnlock) { // Show unlock button only if locked AND we have a configuration to unlock if (locked && this.storageConfigured) { this.toggles.storageUnlock.classList.remove("hidden"); } else { this.toggles.storageUnlock.classList.add("hidden"); } } } getProviderLabel(provider) { if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") { return "Cloudflare R2"; } if (provider === PROVIDERS.S3) { return "Amazon S3"; } return "S3 Compatible"; } describeUrlStyle(provider, forcePathStyle) { if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") { return "Virtual-hosted"; } return forcePathStyle ? "Path-style" : "Virtual-hosted"; } getSummaryCopy(provider) { if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") { return "Uploads will target your Cloudflare R2 bucket."; } return "Uploads will target your S3-compatible bucket."; } // --- Submission --- async handleSubmit() { // Check upload state if (this.activeSource === "upload") { if (this.videoUploadState.status === 'uploading' || this.thumbnailUploadState.status === 'uploading') { alert("Please wait for uploads to complete."); return; } if (this.videoUploadState.status === 'error') { alert("Video upload failed. Please try again."); return; } if (this.videoUploadState.status !== 'complete') { // Fallback: If no file selected, maybe they want to submit without a new file? // (Not supported in this simplified modal, assume file required) alert("Please select a video file and wait for it to upload."); return; } } const audienceFlags = { isNsfw: this.toggles.nsfw?.checked || false, isForKids: this.toggles.kids?.checked || false, }; // Base Metadata const metadata = { title: this.inputs.title?.value?.trim() || "", description: this.inputs.description?.value?.trim() || "", thumbnail: this.inputs.thumbnail?.value?.trim() || "", enableComments: this.toggles.comments?.checked || true, ...audienceFlags, // These might be empty depending on mode, filled below url: "", magnet: "", storagePointer: "", ws: this.inputs.ws?.value?.trim() || "", xs: this.inputs.xs?.value?.trim() || "", infoHash: this.torrentState.infoHash || "", }; // NIP-71 Advanced Data const nip71 = this.nip71FormManager.collectSection("main"); if (nip71) { metadata.nip71 = nip71; } try { if (this.activeSource === "upload") { // We already have the URLs in state metadata.url = this.videoUploadState.url; metadata.magnet = this.torrentState.magnet || ""; const providerLabel = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2" ? "r2" : "s3"; metadata.storagePointer = this.videoUploadState.storagePointer || deriveStoragePointerFromUrl(metadata.url, providerLabel); metadata.ws = this.videoUploadState.url; // WebSeed is same as R2 URL metadata.xs = this.torrentState.url || ""; // NIP-71 IMETA for the main video // We construct a primary imeta entry for the uploaded file const imeta = { url: this.videoUploadState.url, m: this.videoUploadState.file?.type || "video/mp4", x: this.torrentState.infoHash || "", }; // Merge with user provided imeta if any if (!metadata.nip71) metadata.nip71 = {}; if (!metadata.nip71.imeta) metadata.nip71.imeta = []; metadata.nip71.imeta.unshift(imeta); await this.publish(metadata); } else { await this.handleExternalFlow(metadata); } } catch (err) { userLogger.error("Submission failed", err); this.showError(err.message || "An unexpected error occurred."); } } async generateTorrentMetadata({ file, videoPublicUrl } = {}) { // Helper used by immediate upload flow let infoHash = ""; let torrentFile = null; try { const urlList = videoPublicUrl ? [videoPublicUrl] : []; const torrentMetadata = await createTorrentMetadata(file, urlList); infoHash = torrentMetadata?.infoHash || ""; if (torrentMetadata?.torrentFile) { const baseName = file.name.replace(/\.[^/.]+$/, "") || file.name; torrentFile = new File([torrentMetadata.torrentFile], `${baseName}.torrent`, { type: "application/x-bittorrent", }); } } catch (hashErr) { userLogger.warn("Failed to calculate info hash:", hashErr); } const normalizedInfoHash = normalizeInfoHash(infoHash); const hasValidInfoHash = isValidInfoHash(normalizedInfoHash); return { infoHash: normalizedInfoHash, torrentFile, hasValidInfoHash, }; } async resolveUploadIdentifier(file) { try { const infoHash = await calculateTorrentInfoHash(file); const normalized = normalizeInfoHash(infoHash); if (isValidInfoHash(normalized)) { return normalized; } } catch (hashErr) { userLogger.warn("Failed to precompute info hash for storage key:", hashErr); } return ""; } async handleExternalFlow(metadata) { metadata.url = this.inputs.url?.value?.trim() || ""; metadata.magnet = this.inputs.magnet?.value?.trim() || ""; metadata.storagePointer = metadata.storagePointer || deriveStoragePointerFromUrl(metadata.url, "url"); const hasUrl = metadata.url.length > 0; const hasMagnet = metadata.magnet.length > 0; const hasImeta = metadata.nip71?.imeta?.some(v => v.url); if (!hasUrl && !hasMagnet && !hasImeta) { throw new Error("Please provide at least a Video URL or Magnet Link."); } if (!hasUrl && !hasImeta && hasMagnet) { if (!confirm("Magnet-only uploads require active seeding. Proceed?")) return; } await this.publish(metadata); } async publish(metadata) { // Normalize & Publish const { payload, errors } = normalizeVideoNotePayload(metadata); if (errors.length) { throw new Error(getVideoNoteErrorMessage(errors[0])); } this.submitButton.disabled = true; this.submitButton.textContent = "Publishing..."; try { if (this.publishVideoNote) { await this.publishVideoNote(payload); this.showSuccess("Video published successfully!"); this.close(); this.resetForm(); } } finally { this.submitButton.disabled = false; this.submitButton.textContent = "Publish Video"; } } resetUploads() { // Invalidate active uploads this.videoUploadId++; this.thumbnailUploadId++; // Reset State this.videoUploadState = { status: 'idle', progress: 0, url: '', key: '', storagePointer: '', file: null, }; this.thumbnailUploadState = { status: 'idle', progress: 0, url: '', key: '', file: null, }; this.torrentState = { status: 'idle', infoHash: '', magnet: '', url: '', // xs (torrent file url) file: null, }; // Reset UI if (this.sourceSections.progress) { this.sourceSections.progress.classList.add("hidden"); } if (this.sourceSections.thumbnailProgress) { this.sourceSections.thumbnailProgress.classList.add("hidden"); } if (this.sourceSections.results) { this.sourceSections.results.classList.add("hidden"); } // Clear Result Inputs if (this.results?.videoUrl) this.results.videoUrl.value = ""; if (this.results?.magnet) this.results.magnet.value = ""; if (this.results?.torrentUrl) this.results.torrentUrl.value = ""; // Reset Inputs if (this.inputs?.file) { this.inputs.file.value = ""; } if (this.inputs?.thumbnailFile) { this.inputs.thumbnailFile.value = ""; } // Re-enable thumbnail input if it was disabled by an upload if (this.inputs?.thumbnail) { this.inputs.thumbnail.disabled = false; if (this.inputs.thumbnail.placeholder && this.inputs.thumbnail.placeholder.startsWith("Selected:")) { this.inputs.thumbnail.placeholder = "https://example.com/thumbnail.jpg"; } } } resetForm() { this.form.reset(); this.nip71FormManager.resetSection("main"); // Restore defaults this.setSourceMode("upload"); this.toggles.comments.checked = true; this.toggles.nsfw.checked = false; this.toggles.kids.checked = false; this.resetUploads(); } // --- Modal Control --- async open({ triggerElement } = {}) { try { await this.load(); } catch (error) { devLogger.error("[UploadModal] Failed to load modal template:", error); } if (!this.root) return; this.root.classList.remove("hidden"); this.setGlobalModalState("upload", true); this.isVisible = true; // Refresh lock state on open if (this.storageService) { const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false; this.updateLockUi(); // Always attempt load to refresh configuration status (even if locked) this.loadFromStorage().catch(err => { userLogger.warn("Failed to refresh storage state on open:", err); }); } this.modalAccessibility?.activate({ triggerElement }); } close() { if (!this.root) return; this.resetUploads(); this.root.classList.add("hidden"); this.setGlobalModalState("upload", false); this.isVisible = false; this.modalAccessibility?.deactivate(); } addEventListener(type, listener) { this.eventTarget.addEventListener(type, listener); } emit(type, detail) { this.eventTarget.dispatchEvent(new CustomEvent(type, { detail })); } getRoot() { return this.root; } setupModalAccessibility() { if (!this.root) return; if (this.modalAccessibility?.destroy) this.modalAccessibility.destroy(); this.modalAccessibility = createModalAccessibility({ root: this.root, backdrop: this.root.querySelector(".bv-modal-backdrop") || this.root, panel: this.root.querySelector(".modal-sheet") || this.root, onRequestClose: () => this.close() }); } destroy() { this.r2Unsubscribes.forEach(u => u && u()); if (this.modalAccessibility?.destroy) this.modalAccessibility.destroy(); this.root = null; this.container = null; } }