diff --git a/components/profile-modal.html b/components/profile-modal.html index fd9bb6e3..f6a268a9 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1061,9 +1061,18 @@ data-orientation="vertical" >
Files in your bucket that no current video references — safe to remove to reclaim space. diff --git a/js/ui/profileModal/MyVideosController.js b/js/ui/profileModal/MyVideosController.js index c02c9e78..2d086e8d 100644 --- a/js/ui/profileModal/MyVideosController.js +++ b/js/ui/profileModal/MyVideosController.js @@ -50,6 +50,8 @@ export class MyVideosController { this.refreshBtn = null; this.orphansSection = null; this.orphanListEl = null; + this.orphanDeleteAllBtn = null; + this.orphanKeys = []; this.autoShareRow = null; this.autoShareInput = null; this.loading = false; @@ -67,6 +69,8 @@ export class MyVideosController { document.getElementById("profileMyVideosOrphans") || null; this.orphanListEl = document.getElementById("profileMyVideosOrphanList") || null; + this.orphanDeleteAllBtn = + document.getElementById("profileMyVideosOrphanDeleteAll") || null; this.autoShareRow = document.getElementById("profileMyVideosAutoShareRow") || null; this.autoShareInput = @@ -88,6 +92,11 @@ export class MyVideosController { setAutoShareEnabled(pubkey, this.autoShareInput.checked === true); }); } + if (this.orphanDeleteAllBtn instanceof HTMLElement) { + this.orphanDeleteAllBtn.addEventListener("click", () => { + void this.handleDeleteAllOrphans(); + }); + } } refreshAutoShareToggle(pubkey) { @@ -230,9 +239,16 @@ export class MyVideosController { 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; } @@ -289,6 +305,56 @@ export class MyVideosController { } } + // 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 (typeof window !== "undefined" && typeof window.confirm === "function") { + if ( + !window.confirm( + `Permanently delete all ${keys.length} orphaned files from your bucket?`, + ) + ) { + 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 ""; diff --git a/tests/my-videos-bulk-orphan-delete.test.mjs b/tests/my-videos-bulk-orphan-delete.test.mjs new file mode 100644 index 00000000..547a3343 --- /dev/null +++ b/tests/my-videos-bulk-orphan-delete.test.mjs @@ -0,0 +1,102 @@ +// #8: a one-click "Delete all" bulk action for orphaned storage objects in My Videos. +// It must (a) send EVERY listed orphan key in a single deleteStorageKeys call, (b) be +// confirmation-gated, (c) refresh on success, and (d) surface storage-locked + partial +// outcomes honestly (never claim success when nothing was deleted). + +import "./test-helpers/setup-localstorage.mjs"; +import test, { afterEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { MyVideosController } from "../js/ui/profileModal/MyVideosController.js"; + +const KEYS = ["u/n/a/v.mp4", "u/n/a/v.torrent", "u/n/b/old.mp4"]; + +function makeController({ deleteResult, confirm = true } = {}) { + const calls = { deleteKeys: [], success: [], error: [], populated: 0 }; + const mainController = { + services: { + r2Service: { + deleteStorageKeys: async ({ keys, pubkey }) => { + calls.deleteKeys.push({ keys, pubkey }); + return deleteResult; + }, + }, + }, + showSuccess: (m) => calls.success.push(m), + showError: (m) => calls.error.push(m), + normalizeHexPubkey: (v) => v, + getActivePubkey: () => "abc", + }; + const c = new MyVideosController(mainController); + c.pubkey = "abc"; + c.orphanKeys = KEYS.slice(); + c.populate = async () => { + calls.populated += 1; + }; + globalThis.window = { confirm: () => confirm }; + // The controller guards DOM writes with `instanceof HTMLElement`; define a stand-in + // so the check evaluates (to false, since we run without a real DOM) instead of + // throwing ReferenceError in this non-jsdom test. + if (typeof globalThis.HTMLElement === "undefined") { + globalThis.HTMLElement = class {}; + } + return { c, calls }; +} + +afterEach(() => { + delete globalThis.window; + delete globalThis.HTMLElement; +}); + +test("deletes every orphan key in a single call and refreshes on full success", async () => { + const { c, calls } = makeController({ + deleteResult: { ok: true, deleted: KEYS.slice() }, + }); + await c.handleDeleteAllOrphans(); + + assert.equal(calls.deleteKeys.length, 1, "exactly one bulk delete call"); + assert.deepEqual(calls.deleteKeys[0].keys, KEYS, "all keys sent at once"); + assert.equal(calls.deleteKeys[0].pubkey, "abc"); + assert.equal(calls.populated, 1, "list refreshed after delete"); + assert.equal(calls.success.length, 1); + assert.match(calls.success[0], /all 3/i); + assert.equal(calls.error.length, 0); +}); + +test("cancelling the confirmation deletes nothing", async () => { + const { c, calls } = makeController({ + deleteResult: { ok: true, deleted: KEYS.slice() }, + confirm: false, + }); + await c.handleDeleteAllOrphans(); + assert.equal(calls.deleteKeys.length, 0, "no delete when the user cancels"); + assert.equal(calls.populated, 0); +}); + +test("storage-locked surfaces an unlock prompt and does not refresh or claim success", async () => { + const { c, calls } = makeController({ + deleteResult: { ok: false, reason: "storage-locked" }, + }); + await c.handleDeleteAllOrphans(); + assert.equal(calls.success.length, 0); + assert.equal(calls.populated, 0); + assert.equal(calls.error.length, 1); + assert.match(calls.error[0], /unlock storage/i); +}); + +test("a partial delete reports how many succeeded and how many failed", async () => { + const { c, calls } = makeController({ + deleteResult: { ok: true, deleted: KEYS.slice(0, 2) }, // 2 of 3 + }); + await c.handleDeleteAllOrphans(); + assert.equal(calls.populated, 1); + assert.equal(calls.success.length, 1); + assert.match(calls.success[0], /2/); + assert.match(calls.success[0], /1/); +}); + +test("an empty orphan list is a no-op (no delete call)", async () => { + const { c, calls } = makeController({ deleteResult: { ok: true, deleted: [] } }); + c.orphanKeys = []; + await c.handleDeleteAllOrphans(); + assert.equal(calls.deleteKeys.length, 0); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index cc030a99..a30a1c99 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -507,8 +507,16 @@ toward freshness and looked identical. Gave each a structural identity: ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no live note references and offers per-file delete (under the user's prefix only). -- [ ] Remaining (optional): a one-click **bulk** "delete all orphans" action, and/or - surfacing the same control from the Storage settings pane. +- [x] **Bulk "Delete all" orphans — DONE 2026-06-30.** Added a "Delete all (N)" button to + the orphaned-objects header in My Videos (shown only when >1 orphan). One + confirmation → a single `deleteStorageKeys({ keys: allOrphans, pubkey })` call + (already array-capable + unlock-gated + prefix-scoped). Refreshes the list on + success and reports partial outcomes honestly ("Removed 2 files (1 could not be + deleted)"); surfaces storage-locked as an unlock prompt. Tests: + `tests/my-videos-bulk-orphan-delete.test.mjs` (5 — all-keys-in-one-call, cancel, + storage-locked, partial, empty no-op). Build + lint clean; My Videos suite 37/37. +- [ ] Remaining (optional): surface the same orphan control from the Storage settings + pane (currently only in My Videos). ### 9. Cap the cold-login relay-REQ storm further - [x] **Biggest source fixed** (`a8e9f520`): the community-blacklist load fired one