Files
bitvid/js/ui/components/UploadModal.js
T
thePR0M3TH3ANandClaude Opus 4.8 d23cfa6697 feat(blossom): P2P for Blossom videos via a WebTorrent-metadata companion event (#30)
Blossom servers reject a .torrent (415), so a Blossom video's magnet is webseed-only and can't bootstrap WebTorrent. This restores P2P without hosting a .torrent:

- Upload hardening: correct Content-Type (overrides empty/generic application/octet-stream), HTTP status surfaced in errors, and best-effort .torrent sidecar so a rejected sidecar no longer sinks the upload (URL-first fallback).

- Publish: when the .torrent can't be hosted, publish the piece-map as an infohash-keyed companion event (kind 30078, d=bitvid:torrent:<infohash>) and ship a ws= magnet. Gated by FEATURE_BLOSSOM_TORRENT_METADATA (default off = no trace); size-capped.

- Playback: torrentMetadataService lazily fetches the companion, VERIFIES the reconstructed infohash === the magnet's btih (untrusted content), sources the webseed from the video event, and hands the .torrent buffer to WebTorrent so it streams with zero peers. Relay reads go through the subscription manager.

Inherited by any upload-based media (shorts, audio-publish); excludes ingest/live. See docs/blossom-torrent-metadata-plan.md. Includes a Prettier reflow in css/tailwind.source.css.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 14:08:53 -04:00

1541 lines
53 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 {
BLOSSOM_PROVIDER,
isBlossomProvider,
} from "../../services/blossomService.js";
import {
buildStoragePointerValue,
buildStoragePrefixFromKey,
deriveStoragePointerFromUrl,
} from "../../utils/storagePointer.js";
import {
nostrClient,
getActiveSigner,
requestDefaultExtensionPermissions,
} from "../../nostrClientFacade.js";
import { UI_FEEDBACK_DELAY_MS } from "../../constants.js";
import { showConfirm } from "../confirmDialog.js";
import {
ensureStorageUnlockedForUpload,
resetThumbnailPicker,
promptStoredNsecUnlock,
pickTargetConnection,
renderConnectionPicker,
} from "./uploadModalStorageUnlock.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,
ensureSigner,
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),
getSigner: () => getActiveSigner(),
signAndPublishEvent: (event) => nostrClient.signAndPublishEvent(event),
});
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;
// Shared signer gate (app.ensureEncryptionCapableSigner): silently restores a
// kept-unlocked key or shows ONE passphrase prompt, so the file pickers can
// unlock storage inline instead of dead-ending (#56).
this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : 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;
// Blossom connections have no encrypted secret, so they never need a PIN
// unlock — the upload authorizes with the Nostr signer instead.
this.activeProviderIsBlossom = false;
// #44: per-modal upload-destination override (null = account default).
this.selectedConnectionId = null;
// 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"),
connectionPicker: $("#storage-connection-picker"),
};
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"),
// #44: upload-destination picker
storageConnection: $("#select-storage-connection"),
};
// 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());
}
if (this.inputs.storageConnection) {
// #44: switching the destination re-resolves summary + credentials.
this.inputs.storageConnection.addEventListener("change", () => {
this.selectedConnectionId = this.inputs.storageConnection.value || null;
void this.loadFromStorage();
});
}
// 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.activeProviderIsBlossom) {
// #56: unlock inline (silent restore or one passphrase prompt) and
// continue with this same file pick instead of erroring. Blossom has no
// secret to unlock — it authorizes with the Nostr signer at upload time.
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
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.showError("Configure storage before uploading a thumbnail.");
this.resetThumbnailPicker();
return;
}
if (!this.isStorageUnlocked && !this.activeProviderIsBlossom) {
// #56: prompt to unlock inline instead of silently dropping the pick.
// Blossom needs no unlock (signer-authorized at upload time).
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
this.resetThumbnailPicker();
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);
// #44: a per-modal selection wins over the account default.
const { targetConn, defaultConn } = pickTargetConnection(this, connections);
renderConnectionPicker(this, connections, targetConn, defaultConn);
if (targetConn) {
this.storageConfigured = true;
this.activeProvider = targetConn.provider;
this.activeProviderIsBlossom = isBlossomProvider(targetConn.provider);
// UI Updates
this.toggleStorageView("summary");
const providerName = this.getProviderLabel(targetConn.provider || targetConn.meta?.provider);
const blossomServers = Array.isArray(targetConn.meta?.servers)
? targetConn.meta.servers
: [];
const bucketName = this.activeProviderIsBlossom
? `${blossomServers.length} server${blossomServers.length === 1 ? "" : "s"}`
: targetConn.meta?.bucket || "Unknown Bucket";
const urlStyle = this.activeProviderIsBlossom
? "Nostr-native (content-addressed)"
: 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.activeProviderIsBlossom) {
// No secret to decrypt — servers live in the plaintext meta.
this.activeCredentials = {
provider: BLOSSOM_PROVIDER,
servers: blossomServers,
meta: targetConn.meta || {},
};
} else 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.activeProviderIsBlossom = false;
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;
}
}
}
// #36 / #56 storage-unlock glue lives in uploadModalStorageUnlock.js (file-size
// budget); these stay as instance methods so callers/tests are unchanged.
async ensureStorageUnlockedForUpload() {
return ensureStorageUnlockedForUpload(this);
}
resetThumbnailPicker() {
resetThumbnailPicker(this);
}
promptStoredNsecUnlock(pubkey) {
return promptStoredNsecUnlock(this, pubkey);
}
updateLockUi() {
// Blossom has no secret to unlock — never show it as "locked".
const locked = !this.isStorageUnlocked && !this.activeProviderIsBlossom;
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 (isBlossomProvider(provider)) {
return "Blossom";
}
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;
}
}