[ui] #8: one-click "Delete all" for orphaned storage objects in My Videos

The My Videos tab already lists bucket objects no live note references and offers
per-file delete; this adds the bulk action. A "Delete all (N)" button (shown only when
there's more than one orphan) confirms once, then removes every listed key in a single
deleteStorageKeys({ keys, pubkey }) call — which is already array-capable, unlock-gated,
and scoped to the user's prefix. Refreshes the list on success, reports partial results
honestly ("Removed 2 files (1 could not be deleted)"), and surfaces storage-locked as an
unlock prompt instead of a false success.

Tests: tests/my-videos-bulk-orphan-delete.test.mjs (5 — all keys sent in one call,
cancel deletes nothing, storage-locked prompt, partial-count message, empty no-op).
Build + lint clean; My Videos suite 37/37.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 11:39:57 -04:00
1 parent a673ee96aa
commit 1144bde881
4 files changed
+190 -5

No files matched your search

+12 -3
View File
@@ -1061,9 +1061,18 @@
data-orientation="vertical"
></ul>
<div id="profileMyVideosOrphans" class="space-y-2 hidden">
<h4 class="text-sm font-semibold text-text">
Orphaned storage objects
</h4>
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold text-text">
Orphaned storage objects
</h4>
<button
id="profileMyVideosOrphanDeleteAll"
type="button"
class="btn-ghost focus-ring text-xs text-status-danger"
>
Delete all
</button>
</div>
<p class="text-2xs text-muted">
Files in your bucket that no current video references — safe to
remove to reclaim space.
+66
View File
@@ -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 "";
+102
View File
@@ -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);
});
+10 -2
View File
@@ -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