import { devLogger } from "../../utils/logger.js"; import { nostrClient } from "../../nostrClientFacade.js"; import { getApplication } from "../../applicationContext.js"; import { collapseUserVideos } from "./myVideosData.js"; import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js"; import { reconcileStorage } from "./myVideosReconcile.js"; import { FEATURE_NIP71_MIRROR } from "../../constants.js"; import { nip71MirrorService } from "../../services/nip71MirrorService.js"; import { showConfirm } from "../confirmDialog.js"; import { isMirrorEnabled, setMirrorEnabled, resolveMirrorToggle, isAutoShareEnabled, setAutoShareEnabled, } from "../../services/nip71MirrorFlags.js"; const MIRROR_REASON_TEXT = { private: "Private videos can't be shared to other apps.", "nsfw-blocked": "NSFW videos aren't shared from this instance.", "no-url": "Needs a hosted (HTTPS) URL to appear on other apps.", invalid: "This video can't be mirrored.", }; // Maps a health severity to a status-pill style, mirroring // ProfileRelayController.applyStatusPill so the look stays consistent. const PILL_BASE = "inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-semibold whitespace-nowrap"; const PILL_BY_SEVERITY = { error: "bg-status-danger/15 text-status-danger", warning: "bg-status-warning/15 text-status-warning", info: "bg-surface-strong/40 text-muted", ok: "bg-status-success/15 text-status-success", }; /** * "My Videos" management pane: lists the logged-in user's published videos (all * versions collapsed to their current state, including tombstoned ones) with a * health pill so issues like missing sources or orphaned storage are obvious. * * Phase 1 (this controller): note-side health + display. Later steps add the * hosted-URL HEAD probe, per-row actions, and bucket reconciliation. */ export class MyVideosController { constructor(mainController) { this.mainController = mainController; this.listEl = null; this.summaryEl = null; this.loadingEl = null; this.emptyEl = null; this.refreshBtn = null; this.orphansSection = null; this.orphanListEl = null; this.orphanDeleteAllBtn = null; this.orphanKeys = []; this.autoShareRow = null; this.autoShareInput = null; this.loading = false; this.publicBaseUrl = ""; this.pubkey = ""; } cacheDomReferences() { this.listEl = document.getElementById("profileMyVideosList") || null; this.summaryEl = document.getElementById("profileMyVideosSummary") || null; this.loadingEl = document.getElementById("profileMyVideosLoading") || null; this.emptyEl = document.getElementById("profileMyVideosEmpty") || null; this.refreshBtn = document.getElementById("profileMyVideosRefreshBtn") || null; this.orphansSection = document.getElementById("profileMyVideosOrphans") || null; this.orphanListEl = document.getElementById("profileMyVideosOrphanList") || null; this.orphanDeleteAllBtn = document.getElementById("profileMyVideosOrphanDeleteAll") || null; this.autoShareRow = document.getElementById("profileMyVideosAutoShareRow") || null; this.autoShareInput = document.getElementById("profileMyVideosAutoShare") || null; } registerEventListeners() { if (this.refreshBtn instanceof HTMLElement) { this.refreshBtn.addEventListener("click", () => { void this.populate({ forceFetch: true }); }); } if (this.autoShareInput instanceof HTMLInputElement) { this.autoShareInput.addEventListener("change", () => { const pubkey = this.getPubkey(); if (!pubkey) { return; } setAutoShareEnabled(pubkey, this.autoShareInput.checked === true); }); } if (this.orphanDeleteAllBtn instanceof HTMLElement) { this.orphanDeleteAllBtn.addEventListener("click", () => { void this.handleDeleteAllOrphans(); }); } } refreshAutoShareToggle(pubkey) { if (!(this.autoShareRow instanceof HTMLElement)) { return; } const eligible = FEATURE_NIP71_MIRROR && !!pubkey; this.autoShareRow.classList.toggle("hidden", !eligible); if (eligible && this.autoShareInput instanceof HTMLInputElement) { this.autoShareInput.checked = isAutoShareEnabled(pubkey); } } getPubkey() { return this.mainController.normalizeHexPubkey( this.mainController.getActivePubkey(), ); } async resolvePublicBaseUrl(pubkey) { const r2Service = this.mainController.services?.r2Service; if (!r2Service || typeof r2Service.resolveConnection !== "function") { return ""; } try { const npub = this.mainController.safeEncodeNpub?.(pubkey) || ""; if (!npub) { return ""; } const settings = await r2Service.resolveConnection(npub); return (settings?.publicBaseUrl || settings?.baseDomain || "").trim(); } catch (err) { devLogger.warn("[myVideos] Failed to resolve storage base URL:", err); return ""; } } setLoading(on) { if (this.loadingEl instanceof HTMLElement) { this.loadingEl.classList.toggle("hidden", !on); } if (this.refreshBtn instanceof HTMLElement) { this.refreshBtn.disabled = !!on; } } async populate({ forceFetch = false } = {}) { if (this.loading) { return; } if (!(this.listEl instanceof HTMLElement)) { return; } const pubkey = this.getPubkey(); this.refreshAutoShareToggle(pubkey); if (!pubkey) { this.renderRows([]); this.setSummary(""); return; } this.loading = true; this.pubkey = pubkey; this.setLoading(true); try { this.publicBaseUrl = await this.resolvePublicBaseUrl(pubkey); const nostrService = this.mainController.services?.nostrService; if (forceFetch && nostrService?.fetchVideosByAuthors) { try { await nostrService.fetchVideosByAuthors([pubkey]); } catch (err) { devLogger.warn("[myVideos] fetchVideosByAuthors failed:", err); } } const all = nostrClient && nostrClient.allEvents ? Array.from(nostrClient.allEvents.values()) : []; const rows = collapseUserVideos(all, pubkey); this.renderRows(rows); this.setSummary(this.buildSummary(rows)); // Bucket reconciliation runs after the note-side list is shown so the UI is // responsive; it then layers missing-file pills + the orphan section on top. void this.runReconciliation(rows, pubkey); } finally { this.loading = false; this.setLoading(false); } } async runReconciliation(rows, pubkey) { const r2Service = this.mainController.services?.r2Service; if (!r2Service || typeof r2Service.listVideoStorageObjects !== "function") { this.renderOrphans([]); return; } let listing; try { listing = await r2Service.listVideoStorageObjects({ pubkey }); } catch (err) { devLogger.warn("[myVideos] listVideoStorageObjects failed:", err); this.renderOrphans([]); return; } if (!listing || !listing.ok) { // Locked storage etc. — nothing to reconcile against; leave note-side // health as-is and hide the orphan section. this.renderOrphans([]); return; } const { missing, orphanKeys } = reconcileStorage({ videos: rows, bucketKeys: listing.keys, publicBaseUrl: this.publicBaseUrl, }); for (const entry of missing) { this.markRowMissing(entry.video); } this.renderOrphans(orphanKeys); } markRowMissing(video) { if (!(this.listEl instanceof HTMLElement) || !video?.id) { return; } const li = this.listEl.querySelector( `li[data-video-id="${CSS.escape(video.id)}"]`, ); const pill = li?.querySelector('[data-role="health-pill"]'); if (li instanceof HTMLElement) { li.dataset.health = "missing-file"; } if (pill instanceof HTMLElement) { pill.className = `${PILL_BASE} ${PILL_BY_SEVERITY.warning}`; pill.textContent = "Missing file"; } } renderOrphans(keys) { const list = Array.isArray(keys) ? keys : []; this.orphanKeys = list; if (this.orphansSection instanceof HTMLElement) { this.orphansSection.classList.toggle("hidden", list.length === 0); } if (this.orphanDeleteAllBtn instanceof HTMLElement) { this.orphanDeleteAllBtn.textContent = list.length > 1 ? `Delete all (${list.length})` : "Delete all"; // Only worth a bulk action when there's more than one file. this.orphanDeleteAllBtn.classList.toggle("hidden", list.length < 2); } if (!(this.orphanListEl instanceof HTMLElement)) { return; } this.orphanListEl.replaceChildren(); for (const key of list) { this.orphanListEl.appendChild(this.buildOrphanRow(key)); } } buildOrphanRow(key) { const li = document.createElement("li"); li.className = "card flex items-center justify-between gap-3 p-3 border border-border/60"; const label = document.createElement("span"); label.className = "truncate text-2xs text-muted font-mono"; label.textContent = key; label.title = key; const del = this.buildActionButton("Delete file", () => { void this.handleDeleteOrphan(key); }, true); li.appendChild(label); li.appendChild(del); return li; } async handleDeleteOrphan(key) { const r2Service = this.mainController.services?.r2Service; if (!r2Service || typeof r2Service.deleteStorageKeys !== "function") { return; } if ( !(await showConfirm(`Permanently delete this file from your bucket?\n\n${key}`, { confirmLabel: "Delete", danger: true, })) ) { return; } let result; try { result = await r2Service.deleteStorageKeys({ keys: [key], pubkey: this.pubkey }); } catch (err) { devLogger.warn("[myVideos] deleteStorageKeys failed:", err); this.mainController.showError?.("Failed to delete the file. Please try again."); return; } if (result?.ok && result.deleted?.length) { this.mainController.showSuccess?.("Removed the orphaned file from your bucket."); void this.populate({ forceFetch: false }); } else if (result?.reason === "storage-locked") { this.mainController.showError?.("Unlock storage first, then delete the file."); } else { this.mainController.showError?.("Failed to delete the file. Please try again."); } } // Bulk "delete all orphans" — one call removes every listed orphaned object (each is // already confirmed safe by reconcileStorage: no live note references it, under the // user's prefix only). deleteStorageKeys takes the whole key array at once. async handleDeleteAllOrphans() { const r2Service = this.mainController.services?.r2Service; const keys = Array.isArray(this.orphanKeys) ? this.orphanKeys.slice() : []; if (!r2Service || typeof r2Service.deleteStorageKeys !== "function" || !keys.length) { return; } if ( !(await showConfirm( `Permanently delete all ${keys.length} orphaned files from your bucket?`, { confirmLabel: "Delete all", danger: true }, )) ) { return; } if (this.orphanDeleteAllBtn instanceof HTMLElement) { this.orphanDeleteAllBtn.disabled = true; this.orphanDeleteAllBtn.textContent = "Deleting…"; } let result; try { result = await r2Service.deleteStorageKeys({ keys, pubkey: this.pubkey }); } catch (err) { devLogger.warn("[myVideos] bulk deleteStorageKeys failed:", err); this.mainController.showError?.("Failed to delete the files. Please try again."); return; } finally { if (this.orphanDeleteAllBtn instanceof HTMLElement) { this.orphanDeleteAllBtn.disabled = false; } } const deleted = result?.deleted?.length || 0; if (result?.ok && deleted) { const failed = keys.length - deleted; this.mainController.showSuccess?.( failed > 0 ? `Removed ${deleted} files (${failed} could not be deleted).` : `Removed all ${deleted} orphaned files from your bucket.`, ); void this.populate({ forceFetch: false }); } else if (result?.reason === "storage-locked") { this.mainController.showError?.("Unlock storage first, then delete the files."); } else { this.mainController.showError?.("Failed to delete the files. Please try again."); } } buildSummary(rows) { if (!rows.length) { return ""; } const counts = { issues: 0, deleted: 0 }; for (const video of rows) { const health = classifyVideoHealth(video, { publicBaseUrl: this.publicBaseUrl, }); if (health.severity === "error" || health.severity === "warning") { counts.issues += 1; } if (health.status === "deleted") { counts.deleted += 1; } } const parts = [`${rows.length} video${rows.length === 1 ? "" : "s"}`]; if (counts.issues) { parts.push(`${counts.issues} need${counts.issues === 1 ? "s" : ""} attention`); } if (counts.deleted) { parts.push(`${counts.deleted} deleted`); } return parts.join(" · "); } setSummary(text) { if (this.summaryEl instanceof HTMLElement) { this.summaryEl.textContent = text || ""; } } renderRows(rows) { if (!(this.listEl instanceof HTMLElement)) { return; } this.listEl.replaceChildren(); const hasRows = Array.isArray(rows) && rows.length > 0; if (this.emptyEl instanceof HTMLElement) { this.emptyEl.classList.toggle("hidden", hasRows); } if (!hasRows) { return; } for (const video of rows) { this.listEl.appendChild(this.buildRow(video)); } // After the synchronous render (which used the local flag), correct the mirror // labels from relay truth in the background. Fire-and-forget. this.refreshMirrorStates(rows); } buildRow(video) { const health = classifyVideoHealth(video, { publicBaseUrl: this.publicBaseUrl, }); const li = document.createElement("li"); li.className = "card flex items-center gap-3 p-3 border border-border/60"; li.dataset.health = health.status; if (typeof video.id === "string" && video.id) { li.dataset.videoId = video.id; } li.appendChild(this.buildThumb(video)); const info = document.createElement("div"); info.className = "min-w-0 flex-1"; const titleRow = document.createElement("div"); titleRow.className = "flex items-center gap-2 min-w-0"; const title = document.createElement("span"); title.className = "truncate font-medium text-text"; title.textContent = (typeof video.title === "string" && video.title.trim()) || "Untitled video"; const pill = document.createElement("span"); pill.className = `${PILL_BASE} ${PILL_BY_SEVERITY[health.severity] || PILL_BY_SEVERITY.info}`; pill.textContent = health.label; pill.dataset.role = "health-pill"; titleRow.appendChild(title); titleRow.appendChild(pill); const meta = document.createElement("p"); meta.className = "mt-1 text-2xs text-muted"; meta.textContent = this.describeSource(video); info.appendChild(titleRow); info.appendChild(meta); li.appendChild(info); li.appendChild(this.buildActions(video)); return li; } buildThumb(video) { const wrap = document.createElement("div"); wrap.className = "h-12 w-20 shrink-0 overflow-hidden rounded bg-surface-strong/40"; const url = typeof video.thumbnail === "string" ? video.thumbnail.trim() : ""; if (url) { const img = document.createElement("img"); img.className = "h-full w-full object-cover"; img.loading = "lazy"; img.alt = ""; img.src = url; // A broken/missing thumbnail leaves the placeholder box rather than the // browser's broken-image glyph. img.addEventListener("error", () => img.remove()); wrap.appendChild(img); } return wrap; } buildActions(video) { const actions = document.createElement("div"); actions.className = "flex items-center gap-2 shrink-0"; // Active videos can be edited or deleted. Cleaning up a DELETED video's // orphaned file needs the bucket listing (the tombstone scrubbed its URL), // so that action arrives in Phase 2 (storage reconciliation). if (!video.deleted) { actions.appendChild( this.buildActionButton("Edit", () => this.handleEdit(video)), ); const mirrorBtn = this.buildMirrorButton(video); if (mirrorBtn) { actions.appendChild(mirrorBtn); } actions.appendChild( this.buildActionButton("Delete", () => this.handleDelete(video), true), ); } return actions; } // Opt-in "publish to other Nostr video apps (NIP-71)" toggle. Reflects the // per-video opt-in flag; disabled (with a reason) for ineligible videos. buildMirrorButton(video) { if (!FEATURE_NIP71_MIRROR) { return null; } const enabled = isMirrorEnabled(this.pubkey, video?.videoRootId); const eligibility = nip71MirrorService.canMirror(video); if (!enabled && eligibility.ok !== true) { const reason = MIRROR_REASON_TEXT[eligibility.reason] || "Can't be shared."; const btn = this.buildActionButton("Share off", () => { this.mainController.showStatus?.(reason); }); btn.disabled = true; btn.title = reason; btn.classList.add("opacity-50"); return btn; } const btn = this.buildActionButton( enabled ? "Shared ✓" : "Share to apps", (event) => this.handleToggleMirror(video, event?.currentTarget || null), ); // Tag with the root so the batched relay-truth refresh can update this button. const root = video?.videoRootId; if (root) { btn.dataset.mirrorRoot = String(root); } return btn; } // Relay-truth detection (#34): the per-row mirror label initially uses the // device-local flag (synchronous), which can be stale/missing on a fresh device or // after a cache clear. After render, look up the ACTUAL published mirror events // (one batched query for the whole list), reconcile the local flag to match, and // correct any button labels. Fire-and-forget — never blocks the render. async refreshMirrorStates(videos) { if (!FEATURE_NIP71_MIRROR || !this.pubkey) { return; } if (typeof nip71MirrorService.findMirrors !== "function") { return; } let states; try { states = await nip71MirrorService.findMirrors(videos); } catch (error) { // Detection is best-effort; the local flag remains as a hint. return; } if (!(states instanceof Map) || !(this.listEl instanceof HTMLElement)) { return; } for (const video of Array.isArray(videos) ? videos : []) { const root = video?.videoRootId ? String(video.videoRootId) : ""; if (!root) { continue; } const mirrored = states.get(root)?.mirrored === true; // Reconcile the local flag so it's a correct cache going forward. try { setMirrorEnabled(this.pubkey, root, mirrored); } catch (error) { // ignore storage errors } const safeRoot = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(root) : root.replace(/["\\]/g, "\\$&"); const btn = this.listEl.querySelector( `[data-mirror-root="${safeRoot}"]`, ); if (btn instanceof HTMLElement && !btn.disabled) { btn.textContent = mirrored ? "Shared ✓" : "Share to apps"; } } } async handleToggleMirror(video, btn) { const enabled = isMirrorEnabled(this.pubkey, video?.videoRootId); const eligibility = nip71MirrorService.canMirror(video); const decision = resolveMirrorToggle({ enabled, eligibility }); if (decision.action === "blocked") { this.mainController.showError?.( MIRROR_REASON_TEXT[decision.reason] || "This video can't be shared.", ); return; } if (btn) { btn.disabled = true; } try { if (decision.action === "publish") { const result = await nip71MirrorService.publish(video); if (result?.ok) { setMirrorEnabled(this.pubkey, video.videoRootId, true); this.mainController.showSuccess?.( `Shared to other Nostr apps (${result.accepted}/${result.total} relays).`, ); } else { this.mainController.showError?.( "Couldn't share this video. Please try again.", ); } } else { const result = await nip71MirrorService.remove(video); setMirrorEnabled(this.pubkey, video.videoRootId, false); this.mainController.showSuccess?.( result?.ok ? "Removed from other Nostr apps." : "Stopped sharing locally.", ); } } catch (error) { devLogger.warn("[myVideos] mirror toggle failed:", error); this.mainController.showError?.("Sharing action failed. Please try again."); } finally { void this.populate({ forceFetch: true }); } } buildActionButton(label, onClick, danger = false) { const btn = document.createElement("button"); btn.type = "button"; btn.className = danger ? "btn-ghost focus-ring text-xs text-status-danger" : "btn-ghost focus-ring text-xs"; btn.textContent = label; btn.addEventListener("click", onClick); return btn; } handleEdit(video) { const app = getApplication(); // The edit modal is a separate top-level surface; close the (full-screen) // profile modal first so it isn't hidden behind it. this.mainController.hide(); if (app && typeof app.handleEditVideo === "function") { void app.handleEditVideo({ video }); } } handleDelete(video) { const app = getApplication(); this.mainController.hide(); if (app && typeof app.handleFullDeleteVideo === "function") { void app.handleFullDeleteVideo({ video }); } } describeSource(video) { const parts = []; if (typeof video.url === "string" && video.url.trim()) { parts.push( isUrlUnderBase(video.url, this.publicBaseUrl) ? "Hosted URL" : "External URL", ); } if (typeof video.magnet === "string" && video.magnet.trim()) { parts.push("Torrent"); } if (!parts.length) { parts.push("No source"); } const created = Number(video.created_at); if (Number.isFinite(created) && created > 0) { parts.push(new Date(created * 1000).toLocaleDateString()); } return parts.join(" · "); } }