mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
670 lines
22 KiB
JavaScript
670 lines
22 KiB
JavaScript
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);
|
|
if (result?.ok) {
|
|
setMirrorEnabled(this.pubkey, video.videoRootId, false);
|
|
this.mainController.showSuccess?.("Removed from other Nostr apps.");
|
|
} else {
|
|
this.mainController.showError?.(
|
|
"Couldn't remove the shared mirror. Please try again.",
|
|
);
|
|
}
|
|
}
|
|
} 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(" · ");
|
|
}
|
|
}
|