mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Posting an external URL used to skip the torrent/webseed benefit entirely. Now the upload modal's external tab auto-derives a WebTorrent webseed from the URL at publish time: fetch the remote file (CORS-permitting), stream it under a hard size cap, compute the infoHash, and publish a magnet with the URL as the webseed (ws=). Best-effort — CORS failure / over the cap / any error silently degrades to URL-only. Only runs when the user didn't paste their own magnet. - js/utils/externalUrlTorrent.js: fetchBlobWithCap (streams with a 500 MB cap, aborts past it, short-circuits on content-length), buildWebseedMagnet, and deriveExternalUrlTorrent (reuses createTorrentMetadata). Pure + injectable → unit tested without network/WebTorrent. - mediaUploader.deriveTorrentForExternalUrl: derives the ws= magnet and, when storage is unlocked, also hosts the tiny .torrent so the webseed can actually bootstrap P2P (xs=) — best-effort, falls back to ws=-only. - UploadModal.handleExternalFlow calls it; deriveExternalWebseed only hosts the .torrent when storage is unlocked, shows progress on the submit button, and never throws. Tests: tests/external-url-torrent.test.mjs (9 — magnet assembly, name parsing, streamed size-cap enforcement + short-circuit, fetch-failed, derive-with-injected- hash, failure propagation). Lint + build green. Next: the same for editing a URL in the Edit modal. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1495 lines
51 KiB
JavaScript
1495 lines
51 KiB
JavaScript
// components/UploadModal.js
|
|
import { createModalAccessibility } from "./modalAccessibility.js";
|
|
import { Nip71FormManager } from "./nip71FormManager.js";
|
|
import { probeVideoMetadata } from "../../utils/videoProbe.js";
|
|
import { MediaUploader } from "./mediaUploader.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";
|
|
import { showConfirm } from "../confirmDialog.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,
|
|
onRequestUnlock,
|
|
getHashtagSuggestions,
|
|
} = {}) {
|
|
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.getHashtagSuggestions =
|
|
typeof getHashtagSuggestions === "function" ? getHashtagSuggestions : null;
|
|
this.safeEncodeNpub =
|
|
typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
|
|
// Shared storage+torrent upload core (also used by the Edit modal).
|
|
this.mediaUploader = new MediaUploader({
|
|
r2Service: this.r2Service,
|
|
s3Service: this.s3Service,
|
|
storageService: this.storageService,
|
|
getCurrentPubkey: () =>
|
|
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
|
|
safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey),
|
|
});
|
|
this.eventTarget =
|
|
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
|
this.container = container || null;
|
|
this.onRequestStorageSettings = typeof onRequestStorageSettings === "function" ? onRequestStorageSettings : null;
|
|
// Opens the login modal's unlock-saved-key (passphrase) flow — used when a
|
|
// persisted nsec session is locked after a reload.
|
|
this.onRequestUnlock = typeof onRequestUnlock === "function" ? onRequestUnlock : 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;
|
|
await this.maybeAutoUnlockStorage(pubkey);
|
|
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");
|
|
this.hashtagSuggestionsWrap = $("#hashtag-suggestions-wrap");
|
|
this.hashtagSuggestionsList = $("#hashtag-suggestions");
|
|
|
|
// 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 ---
|
|
|
|
// Best-effort: read the file's dimensions/duration so we can persist them
|
|
// (orientation → 34236 short selection + a future "shorts" feed). Never blocks
|
|
// upload; failures just leave the metadata unset.
|
|
async captureVideoMetadata(file) {
|
|
this.capturedMetadata = null;
|
|
if (!file || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
|
|
return;
|
|
}
|
|
const objectUrl = URL.createObjectURL(file);
|
|
try {
|
|
this.capturedMetadata = await probeVideoMetadata(objectUrl);
|
|
} catch (error) {
|
|
this.capturedMetadata = null;
|
|
} finally {
|
|
try {
|
|
URL.revokeObjectURL(objectUrl);
|
|
} catch (revokeError) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleVideoSelection(e) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// Probe dimensions/duration in the background while the upload proceeds.
|
|
void this.captureVideoMetadata(file);
|
|
|
|
if (!this.storageConfigured) {
|
|
this.showError("Please configure storage before selecting a file.");
|
|
e.target.value = ""; // Clear selection
|
|
return;
|
|
}
|
|
if (!this.isStorageUnlocked) {
|
|
this.showError("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 {
|
|
const result = await this.mediaUploader.uploadVideo(file, {
|
|
provider: this.activeProvider,
|
|
credentials: this.activeCredentials,
|
|
onProgress: ({ fraction, label }) => {
|
|
if (this.videoUploadId === currentUploadId) {
|
|
this.updateVideoProgress(fraction, label || undefined);
|
|
}
|
|
},
|
|
});
|
|
|
|
if (this.videoUploadId !== currentUploadId) return; // Zombie guard
|
|
|
|
// Video Complete
|
|
this.videoUploadState.status = 'complete';
|
|
this.videoUploadState.url = result.url;
|
|
this.videoUploadState.key = result.key;
|
|
this.videoUploadState.storagePointer = result.storagePointer;
|
|
if (this.results.videoUrl) this.results.videoUrl.value = result.url;
|
|
|
|
if (result.hasValidInfoHash) {
|
|
this.torrentState.status = 'complete';
|
|
this.torrentState.infoHash = result.infoHash;
|
|
this.torrentState.url = result.torrentUrl;
|
|
this.torrentState.file = result.torrentFile;
|
|
this.torrentState.magnet = result.magnet;
|
|
|
|
if (this.results.magnet) this.results.magnet.value = result.magnet;
|
|
if (this.results.torrentUrl) this.results.torrentUrl.value = result.torrentUrl;
|
|
} else {
|
|
this.torrentState.status = 'skipped'; // Failed hash or invalid
|
|
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";
|
|
|
|
this.showError(`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 { url: publicUrl } = await this.mediaUploader.uploadThumbnail(
|
|
file,
|
|
{
|
|
provider: this.activeProvider,
|
|
credentials: this.activeCredentials,
|
|
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.");
|
|
this.showError("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;
|
|
await this.maybeAutoUnlockStorage(pubkey);
|
|
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
|
|
this.updateLockUi();
|
|
|
|
await this.loadFromStorage();
|
|
}
|
|
}
|
|
|
|
// Silently unlock existing storage when a decrypt-capable signer is already
|
|
// available (e.g. a kept-unlocked nsec restored on refresh, TODO #51) so the
|
|
// upload modal opens Unlocked instead of Locked. Guarded on an EXISTING account
|
|
// so it never creates storage for magnet/URL-only uploaders, and on the signer
|
|
// already being present so it never triggers a fresh permission prompt.
|
|
async maybeAutoUnlockStorage(pubkey) {
|
|
if (!this.storageService || !pubkey) {
|
|
return;
|
|
}
|
|
if (this.storageService.isUnlocked(pubkey)) {
|
|
return;
|
|
}
|
|
const signer = getActiveSigner();
|
|
if (
|
|
!signer ||
|
|
(typeof signer.nip44Decrypt !== "function" &&
|
|
typeof signer.nip04Decrypt !== "function")
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
const hasAccount =
|
|
typeof this.storageService.hasStoredAccount === "function"
|
|
? await this.storageService.hasStoredAccount(pubkey)
|
|
: false;
|
|
if (!hasAccount) {
|
|
return;
|
|
}
|
|
await this.storageService.unlock(pubkey, { signer });
|
|
} catch (error) {
|
|
devLogger?.log?.(
|
|
"[UploadModal] Auto-unlock storage skipped:",
|
|
error?.message || error,
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Hashtag suggestion chips (TODO #45) ---
|
|
|
|
// The hashtags currently entered in the "t" repeater (normalized).
|
|
getSelectedHashtags() {
|
|
if (!this.nip71FormManager) {
|
|
return [];
|
|
}
|
|
try {
|
|
return this.nip71FormManager
|
|
.collectRepeaterValues("main", "t", (entry) =>
|
|
this.nip71FormManager.getFieldValue(entry, "value"),
|
|
)
|
|
.map((value) => this.nip71FormManager.sanitizeHashtagValue(value))
|
|
.filter(Boolean);
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Add a hashtag to the "t" repeater (skips duplicates). Returns true if added.
|
|
addHashtagValue(rawValue) {
|
|
if (!this.nip71FormManager) {
|
|
return false;
|
|
}
|
|
const normalized = this.nip71FormManager.sanitizeHashtagValue(rawValue);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
if (new Set(this.getSelectedHashtags()).has(normalized)) {
|
|
return false;
|
|
}
|
|
const entry = this.nip71FormManager.addRepeaterEntry("main", "t");
|
|
if (!entry) {
|
|
return false;
|
|
}
|
|
this.nip71FormManager.setFieldValue(entry, "value", normalized);
|
|
return true;
|
|
}
|
|
|
|
// Render one-tap chips for the user's most-used past hashtags. Hidden when the
|
|
// user has none (new/first upload).
|
|
renderHashtagSuggestions() {
|
|
const list = this.hashtagSuggestionsList;
|
|
const wrap = this.hashtagSuggestionsWrap;
|
|
if (!(list instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
let suggestions = [];
|
|
if (typeof this.getHashtagSuggestions === "function") {
|
|
try {
|
|
suggestions = this.getHashtagSuggestions({ limit: 12 }) || [];
|
|
} catch (error) {
|
|
suggestions = [];
|
|
}
|
|
}
|
|
|
|
list.textContent = "";
|
|
const usable = Array.isArray(suggestions) ? suggestions : [];
|
|
if (!usable.length) {
|
|
if (wrap instanceof HTMLElement) {
|
|
wrap.classList.add("hidden");
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const item of usable) {
|
|
const tag = this.nip71FormManager
|
|
? this.nip71FormManager.sanitizeHashtagValue(item?.tag)
|
|
: typeof item?.tag === "string"
|
|
? item.tag
|
|
: "";
|
|
if (!tag) {
|
|
continue;
|
|
}
|
|
const chip = document.createElement("button");
|
|
chip.type = "button";
|
|
chip.className =
|
|
"btn-ghost px-3 py-1 text-xs bg-surface border border-border/50 rounded-full";
|
|
chip.dataset.tag = tag;
|
|
chip.textContent = `#${tag}`;
|
|
chip.addEventListener("click", () => {
|
|
this.addHashtagValue(tag);
|
|
this.refreshHashtagSuggestionStates();
|
|
});
|
|
list.appendChild(chip);
|
|
}
|
|
|
|
if (wrap instanceof HTMLElement) {
|
|
wrap.classList.remove("hidden");
|
|
}
|
|
this.refreshHashtagSuggestionStates();
|
|
}
|
|
|
|
// Dim + disable chips whose tag is already in the current tag list.
|
|
refreshHashtagSuggestionStates() {
|
|
const list = this.hashtagSuggestionsList;
|
|
if (!(list instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const selected = new Set(this.getSelectedHashtags());
|
|
list.querySelectorAll("[data-tag]").forEach((chip) => {
|
|
const isSelected = selected.has(chip.dataset.tag);
|
|
chip.classList.toggle("opacity-40", isSelected);
|
|
chip.setAttribute("aria-pressed", isSelected ? "true" : "false");
|
|
if ("disabled" in chip) {
|
|
chip.disabled = isSelected;
|
|
}
|
|
});
|
|
}
|
|
|
|
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) {
|
|
// A persisted ("remember this key") nsec session restores the logged-in
|
|
// pubkey after a reload but NOT the in-memory signer (the key is passphrase-
|
|
// encrypted). Prompt the user to re-unlock their saved key instead of dead-
|
|
// ending — matches the storage pane's behavior (#36).
|
|
if (this.promptStoredNsecUnlock(pubkey)) {
|
|
return;
|
|
}
|
|
this.showError("No signer available to unlock storage.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (signer?.type === "extension" || signer?.type === "nip07") {
|
|
const permissionResult = await requestDefaultExtensionPermissions();
|
|
if (!permissionResult?.ok) {
|
|
this.showError("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);
|
|
this.showError("Failed to unlock storage: " + err.message);
|
|
} finally {
|
|
if (this.toggles.storageUnlock) {
|
|
this.toggles.storageUnlock.textContent = "Unlock";
|
|
this.toggles.storageUnlock.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Returns true if a locked persisted-nsec session was detected (for the active
|
|
// account) and the re-unlock (passphrase) flow was opened, so the caller can stop.
|
|
promptStoredNsecUnlock(pubkey) {
|
|
const client = this.authService?.nostrClient;
|
|
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
|
|
return false;
|
|
}
|
|
let meta = null;
|
|
try {
|
|
meta = client.getStoredSessionActorMetadata();
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") {
|
|
return false;
|
|
}
|
|
const metaPubkey =
|
|
typeof meta.pubkey === "string" ? meta.pubkey.trim().toLowerCase() : "";
|
|
const activePubkey =
|
|
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
|
|
if (activePubkey && metaPubkey && metaPubkey !== activePubkey) {
|
|
return false;
|
|
}
|
|
if (typeof this.onRequestUnlock !== "function") {
|
|
return false;
|
|
}
|
|
this.showError(
|
|
"Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.",
|
|
);
|
|
try {
|
|
this.onRequestUnlock();
|
|
} catch (err) {
|
|
userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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";
|
|
}
|
|
if (provider === PROVIDERS.B2) {
|
|
return "Backblaze B2";
|
|
}
|
|
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.";
|
|
}
|
|
if (provider === PROVIDERS.B2) {
|
|
return "Uploads will target your Backblaze B2 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') {
|
|
this.showError("Please wait for uploads to complete.");
|
|
return;
|
|
}
|
|
if (this.videoUploadState.status === 'error') {
|
|
this.showError("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)
|
|
this.showError("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() || "",
|
|
// Prefer an uploaded thumbnail's URL: selecting a thumbnail file
|
|
// clears+disables the text input (see the file-picker handler), so
|
|
// inputs.thumbnail.value is empty for file uploads. Fall back to a
|
|
// pasted URL when no file was uploaded.
|
|
thumbnail:
|
|
this.thumbnailUploadState?.url ||
|
|
this.inputs.thumbnail?.value?.trim() ||
|
|
"",
|
|
enableComments: this.toggles.comments?.checked || true,
|
|
...audienceFlags,
|
|
|
|
// Auto-captured at file-select (best-effort; absent ⇒ omitted). Drives
|
|
// 34236 short selection + a future "shorts" feed (height > width).
|
|
width: this.capturedMetadata?.width || 0,
|
|
height: this.capturedMetadata?.height || 0,
|
|
duration: this.capturedMetadata?.duration || 0,
|
|
|
|
// 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);
|
|
// The R2/CDN url is the primary webseed; any additional backup
|
|
// webseeds the uploader entered (one per line) follow it.
|
|
// normalizeVideoNotePayload splits + dedupes this list.
|
|
metadata.ws = [
|
|
this.videoUploadState.url,
|
|
this.inputs.ws?.value || "",
|
|
];
|
|
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.");
|
|
}
|
|
|
|
// Auto-derive a webseed torrent from the external URL so the link keeps the
|
|
// P2P benefit (fetch + hash the remote file → magnet with ws=url). Best-effort
|
|
// and size-capped: on CORS / too-large / any error it silently degrades to
|
|
// URL-only. Only runs when the user didn't already paste their own magnet.
|
|
if (hasUrl && !hasMagnet) {
|
|
const derived = await this.deriveExternalWebseed(metadata.url);
|
|
if (derived?.magnet) {
|
|
metadata.magnet = derived.magnet;
|
|
metadata.infoHash = derived.infoHash || metadata.infoHash || "";
|
|
metadata.ws = [metadata.url, this.inputs.ws?.value || ""];
|
|
if (derived.torrentUrl) metadata.xs = derived.torrentUrl;
|
|
}
|
|
}
|
|
|
|
if (!hasUrl && !hasImeta && metadata.magnet.length > 0) {
|
|
if (!(await showConfirm("Magnet-only uploads require active seeding. Proceed?"))) return;
|
|
}
|
|
|
|
await this.publish(metadata);
|
|
}
|
|
|
|
// Best-effort webseed derivation for an external URL (TODO: external-URL P2P).
|
|
// Hosts the tiny .torrent (xs=) only when storage is actually unlocked; otherwise
|
|
// returns a ws=-only magnet. Never throws — returns null so the caller publishes
|
|
// URL-only.
|
|
async deriveExternalWebseed(url) {
|
|
if (typeof this.mediaUploader?.deriveTorrentForExternalUrl !== "function") {
|
|
return null;
|
|
}
|
|
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
|
const canHostTorrent = Boolean(
|
|
this.activeProvider &&
|
|
this.activeCredentials &&
|
|
pubkey &&
|
|
this.storageService?.isUnlocked?.(pubkey),
|
|
);
|
|
const previousLabel = this.submitButton?.textContent;
|
|
if (this.submitButton) {
|
|
this.submitButton.disabled = true;
|
|
this.submitButton.textContent = "Computing torrent hash…";
|
|
}
|
|
try {
|
|
return await this.mediaUploader.deriveTorrentForExternalUrl(url, {
|
|
provider: canHostTorrent ? this.activeProvider : undefined,
|
|
credentials: canHostTorrent ? this.activeCredentials : undefined,
|
|
onProgress: ({ label }) => {
|
|
if (this.submitStatus && label) this.submitStatus.textContent = label;
|
|
},
|
|
});
|
|
} catch (error) {
|
|
userLogger.warn(
|
|
"[UploadModal] External URL webseed derivation skipped:",
|
|
error?.message || error,
|
|
);
|
|
return null;
|
|
} finally {
|
|
if (this.submitButton) {
|
|
this.submitButton.disabled = false;
|
|
if (previousLabel) this.submitButton.textContent = previousLabel;
|
|
}
|
|
if (this.submitStatus) this.submitStatus.textContent = "";
|
|
}
|
|
}
|
|
|
|
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. Auto-unlock first so a kept-unlocked nsec
|
|
// (restored on refresh) opens Unlocked instead of Locked (TODO #51).
|
|
if (this.storageService) {
|
|
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
|
await this.maybeAutoUnlockStorage(pubkey);
|
|
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.renderHashtagSuggestions();
|
|
|
|
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;
|
|
}
|
|
}
|