mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
1541 lines
53 KiB
JavaScript
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;
|
|
}
|
|
}
|