Files
bitvid/js/ui/editModalController.js
thePR0M3TH3ANandClaude Opus 4.8 e81e65ff40 feat(blossom): Phase 2 management — blob delete-on-orphan + kind-10063 server list (#30)
- deleteByUrl maps a Blossom URL back to its content-addressed blob (blobSha256FromUrl) and deletes it via BUD-12, defaulting to the URL's own origin. Wired into the edit-modal orphan cleanup so a superseded Blossom video/thumbnail is reclaimed (best-effort, never blocks the edit).

- BUD-03 kind-10063 server-list discovery: read (serversFromServerListEvent) + build (buildBlossomServerListEvent) + an extracted, testable import/publish helper (blossomServerList.js), plus 'Import from my server list' / 'Publish my server list' controls in the Storage pane's Blossom section.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 10:32:22 -04:00

196 lines
6.5 KiB
JavaScript

import { devLogger } from "../utils/logger.js";
import r2Service from "../services/r2Service.js";
import blossomService, {
blobSha256FromUrl,
} from "../services/blossomService.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
import { readRelayPublishSummary } from "../nostrPublish.js";
export default class EditModalController {
constructor({ services, state, ui, callbacks, helpers }) {
this.services = services;
this.state = state;
this.ui = ui;
this.callbacks = callbacks;
this.helpers = helpers;
}
async open(target) {
try {
const normalizedTarget = this.helpers.normalizeActionTarget(target);
const { triggerElement } = normalizedTarget;
const blacklistedEventIds = this.state.getBlacklistedEventIds();
const isAuthorBlocked = this.callbacks.isAuthorBlocked;
const latestVideos = await this.services.nostrService.fetchVideos({
blacklistedEventIds,
isAuthorBlocked: (pubkey) => isAuthorBlocked(pubkey),
});
const video = await this.helpers.resolveVideoActionTarget({
...normalizedTarget,
preloadedList: latestVideos,
});
// 2) Basic ownership checks
const pubkey = this.state.getPubkey();
if (!pubkey) {
this.ui.showError("Please login to edit videos.");
return;
}
const userPubkey = (pubkey || "").toLowerCase();
const videoPubkey = (video?.pubkey || "").toLowerCase();
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
this.ui.showError("You do not own this video.");
return;
}
const editModal = this.ui.getEditModal();
if (!editModal) {
this.ui.showError("Edit modal is not available right now.");
return;
}
try {
await editModal.load();
} catch (error) {
devLogger.error("Failed to load edit modal:", error);
this.ui.showError(`Failed to initialize edit modal: ${error.message}`);
return;
}
try {
await editModal.open(video, { triggerElement });
} catch (error) {
devLogger.error("Failed to open edit modal:", error);
this.ui.showError("Edit modal is not available right now.");
}
} catch (err) {
devLogger.error("Failed to edit video:", err);
this.ui.showError("Failed to edit video. Please try again.");
}
}
async handleSubmit(event) {
const detail = event?.detail || {};
const { originalEvent, updatedData } = detail;
if (!originalEvent || !updatedData) {
return;
}
const pubkey = this.state.getPubkey();
const editModal = this.ui.getEditModal();
if (!pubkey) {
this.ui.showError("Please login to edit videos.");
if (editModal?.setSubmitState) {
editModal.setSubmitState({ pending: false });
}
return;
}
try {
const editedEvent = await this.services.nostrService.handleEditVideoSubmit({
originalEvent,
updatedData,
pubkey,
});
// Best-effort: when the hosted video URL and/or thumbnail were genuinely
// replaced, the old R2/S3 objects are now orphaned — remove them. For each,
// we require both old and new values to be non-empty and different so we
// never delete an object the new note still references (e.g. the URL was
// merely cleared, or the thumbnail was left unchanged). The video entry
// also takes its sibling .torrent. Never blocks the edit.
try {
const orphans = [];
const oldUrl = detail.video?.url || "";
const newUrl = updatedData?.url || "";
if (updatedData?.urlEdited && oldUrl && newUrl && oldUrl !== newUrl) {
// collectVideoStorageKeys derives the video key + its .torrent.
orphans.push({ url: oldUrl });
}
const oldThumb = detail.video?.thumbnail || "";
const newThumb = updatedData?.thumbnail || "";
if (oldThumb && newThumb && oldThumb !== newThumb) {
orphans.push({ thumbnail: oldThumb });
}
if (orphans.length) {
const cleanup = await r2Service.deleteVideoStorage({
videos: orphans,
pubkey,
});
if (cleanup?.deleted?.length) {
devLogger.log(
`[edit] Removed ${cleanup.deleted.length} superseded storage object(s).`
);
}
// Blossom orphans are content-addressed blobs, not R2/S3 keys — delete
// them via BUD-12 (best-effort, from each blob URL's own origin).
const blossomUrls = orphans
.map((o) => o.url || o.thumbnail || "")
.filter((u) => blobSha256FromUrl(u));
if (blossomUrls.length) {
const activeSigner = getActiveSigner();
if (activeSigner && typeof activeSigner.signEvent === "function") {
const signer = (draft) => activeSigner.signEvent(draft);
for (const url of blossomUrls) {
const res = await blossomService.deleteByUrl({ url, signer });
if (res.deleted?.length) {
devLogger.log(
`[edit] Deleted superseded Blossom blob ${res.sha256}.`
);
}
}
}
}
}
} catch (cleanupErr) {
devLogger.warn(
"[edit] Storage cleanup failed (edit still succeeded):",
cleanupErr
);
}
await this.callbacks.loadVideos();
const videosMap = this.state.getVideosMap();
if (videosMap) {
videosMap.clear();
}
// #49: an all-timeout publish reaches here flagged unconfirmed — say so
// honestly instead of claiming a confirmed update.
const tally = readRelayPublishSummary(editedEvent);
if (tally?.unconfirmed === true && !(tally.accepted > 0)) {
this.ui.showSuccess(
"Video update sent, but no relay has confirmed it yet — it may take a moment to propagate.",
);
} else {
this.ui.showSuccess("Video updated successfully!");
}
if (editModal?.setSubmitState) {
editModal.setSubmitState({ pending: false });
}
if (typeof editModal.close === "function") {
editModal.close();
}
this.callbacks.forceRefreshAllProfiles();
} catch (error) {
devLogger.error("Failed to edit video:", error);
this.ui.showError("Failed to edit video. Please try again.");
if (editModal?.setSubmitState) {
editModal.setSubmitState({ pending: false });
}
}
}
}