mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
10893 lines
331 KiB
JavaScript
10893 lines
331 KiB
JavaScript
// js/app.js
|
||
|
||
import { loadView } from "./viewManager.js";
|
||
import {
|
||
nostrClient,
|
||
recordVideoView,
|
||
updateWatchHistoryList,
|
||
} from "./nostr.js";
|
||
import { torrentClient } from "./webtorrent.js";
|
||
import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js";
|
||
import { accessControl, normalizeNpub } from "./accessControl.js";
|
||
import { safeDecodeMagnet } from "./magnetUtils.js";
|
||
import { extractMagnetHints, normalizeAndAugmentMagnet } from "./magnet.js";
|
||
import { deriveTorrentPlaybackConfig } from "./playbackUtils.js";
|
||
import { URL_FIRST_ENABLED } from "./constants.js";
|
||
import { trackVideoView } from "./analytics.js";
|
||
import { attachHealthBadges } from "./gridHealth.js";
|
||
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||
import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js";
|
||
import { userBlocks } from "./userBlocks.js";
|
||
import {
|
||
loadR2Settings,
|
||
saveR2Settings,
|
||
clearR2Settings,
|
||
buildR2Key,
|
||
buildPublicUrl,
|
||
mergeBucketEntry,
|
||
sanitizeBaseDomain,
|
||
} from "./r2.js";
|
||
import {
|
||
sanitizeBucketName,
|
||
ensureBucket,
|
||
putCors,
|
||
attachCustomDomainAndWait,
|
||
setManagedDomain,
|
||
deriveShortSubdomain,
|
||
} from "./storage/r2-mgmt.js";
|
||
import {
|
||
makeR2Client,
|
||
multipartUpload,
|
||
ensureBucketCors,
|
||
} from "./storage/r2-s3.js";
|
||
import { initQuickR2Upload } from "./r2-quick.js";
|
||
import { createWatchHistoryRenderer } from "./historyView.js";
|
||
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
|
||
import {
|
||
initViewCounter,
|
||
subscribeToVideoViewCount,
|
||
unsubscribeFromVideoViewCount,
|
||
formatViewCount,
|
||
ingestLocalViewEvent,
|
||
} from "./viewCounter.js";
|
||
|
||
function truncateMiddle(text, maxLength = 72) {
|
||
if (!text || typeof text !== "string") {
|
||
return "";
|
||
}
|
||
|
||
if (text.length <= maxLength) {
|
||
return text;
|
||
}
|
||
|
||
const ellipsis = "…";
|
||
const charsToShow = maxLength - ellipsis.length;
|
||
const front = Math.ceil(charsToShow / 2);
|
||
const back = Math.floor(charsToShow / 2);
|
||
return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`;
|
||
}
|
||
|
||
function pointerArrayToKey(pointer) {
|
||
if (!Array.isArray(pointer) || pointer.length < 2) {
|
||
return "";
|
||
}
|
||
|
||
const type = pointer[0] === "a" ? "a" : pointer[0] === "e" ? "e" : "";
|
||
if (!type) {
|
||
return "";
|
||
}
|
||
|
||
const value =
|
||
typeof pointer[1] === "string" ? pointer[1].trim().toLowerCase() : "";
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
const relay =
|
||
pointer.length > 2 && typeof pointer[2] === "string"
|
||
? pointer[2].trim()
|
||
: "";
|
||
|
||
return relay ? `${type}:${value}:${relay}` : `${type}:${value}`;
|
||
}
|
||
|
||
/**
|
||
* Simple "decryption" placeholder for private videos.
|
||
*/
|
||
function fakeDecrypt(str) {
|
||
return str.split("").reverse().join("");
|
||
}
|
||
|
||
const UNSUPPORTED_BTITH_MESSAGE =
|
||
"This magnet link is missing a compatible BitTorrent v1 info hash.";
|
||
|
||
const FALLBACK_THUMBNAIL_SRC = "assets/jpg/video-thumbnail-fallback.jpg";
|
||
const ADMIN_DM_IMAGE_URL =
|
||
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
|
||
const BITVID_WEBSITE_URL = "https://bitvid.network/";
|
||
const MAX_DISCUSSION_COUNT_VIDEOS = 24;
|
||
const VIDEO_EVENT_KIND = 30078;
|
||
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
|
||
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
|
||
/**
|
||
* Local storage keys for cached profile metadata and saved authentication
|
||
* sessions. `SAVED_PROFILES_STORAGE_KEY` stores objects shaped like
|
||
* `{ version: 1, entries: [{ pubkey, npub, name, picture, authType }],
|
||
* activePubkey?: string | null }`. `authType` currently supports `"nip07"`
|
||
* and reserves `"nsec"` for future direct key flows. See docs/nostr-auth.md
|
||
* for extension guidance.
|
||
*/
|
||
const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1";
|
||
const SAVED_PROFILES_STORAGE_KEY = "bitvid:savedProfiles:v1";
|
||
const SAVED_PROFILES_STORAGE_VERSION = 1;
|
||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||
const PROFILE_CACHE_VERSION = 1;
|
||
const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||
// We probe hosted URLs often enough that a naive implementation would spam
|
||
// remote CDNs. A medium-lived cache (roughly 45 minutes) keeps us from
|
||
// hammering dead hosts while still giving the UI timely updates when a CDN
|
||
// recovers. Results are stored both in-memory and in localStorage so other
|
||
// views can reuse them without re-probing.
|
||
const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes
|
||
const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes
|
||
const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds
|
||
const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
|
||
const URL_HEALTH_STORAGE_PREFIX = "bitvid:urlHealth:";
|
||
const urlHealthCache = new Map();
|
||
const urlHealthInFlight = new Map();
|
||
|
||
function removeTrackingScripts(root) {
|
||
if (!root || typeof root.querySelectorAll !== "function") {
|
||
return;
|
||
}
|
||
|
||
root.querySelectorAll("script[src]").forEach((script) => {
|
||
const src = script.getAttribute("src") || "";
|
||
if (TRACKING_SCRIPT_PATTERN.test(src)) {
|
||
script.remove();
|
||
}
|
||
});
|
||
}
|
||
|
||
function getUrlHealthStorageKey(eventId) {
|
||
return `${URL_HEALTH_STORAGE_PREFIX}${eventId}`;
|
||
}
|
||
|
||
function readUrlHealthFromStorage(eventId) {
|
||
if (!eventId || typeof localStorage === "undefined") {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const raw = localStorage.getItem(getUrlHealthStorageKey(eventId));
|
||
if (!raw) {
|
||
return null;
|
||
}
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === "object") {
|
||
return parsed;
|
||
}
|
||
} catch (err) {
|
||
console.warn(`Failed to parse stored URL health for ${eventId}:`, err);
|
||
}
|
||
|
||
removeUrlHealthFromStorage(eventId);
|
||
return null;
|
||
}
|
||
|
||
function writeUrlHealthToStorage(eventId, entry) {
|
||
if (!eventId || typeof localStorage === "undefined") {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
localStorage.setItem(
|
||
getUrlHealthStorageKey(eventId),
|
||
JSON.stringify(entry)
|
||
);
|
||
} catch (err) {
|
||
console.warn(`Failed to persist URL health for ${eventId}:`, err);
|
||
}
|
||
}
|
||
|
||
function removeUrlHealthFromStorage(eventId) {
|
||
if (!eventId || typeof localStorage === "undefined") {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
localStorage.removeItem(getUrlHealthStorageKey(eventId));
|
||
} catch (err) {
|
||
console.warn(`Failed to remove URL health for ${eventId}:`, err);
|
||
}
|
||
}
|
||
|
||
function isUrlHealthEntryFresh(entry, url) {
|
||
if (!entry || typeof entry !== "object") {
|
||
return false;
|
||
}
|
||
|
||
const now = Date.now();
|
||
if (typeof entry.expiresAt !== "number" || entry.expiresAt <= now) {
|
||
return false;
|
||
}
|
||
|
||
if (url && entry.url && entry.url !== url) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Returns the cached health result for a given event ID when the entry is
|
||
* still "fresh". Entries are evicted eagerly once their TTL expires or when
|
||
* the associated URL changes (e.g. a user edits the post to point elsewhere).
|
||
*/
|
||
function readUrlHealthFromCache(eventId, url) {
|
||
if (!eventId) {
|
||
return null;
|
||
}
|
||
|
||
const entry = urlHealthCache.get(eventId);
|
||
if (isUrlHealthEntryFresh(entry, url)) {
|
||
return entry;
|
||
}
|
||
|
||
if (entry) {
|
||
urlHealthCache.delete(eventId);
|
||
}
|
||
|
||
const stored = readUrlHealthFromStorage(eventId);
|
||
if (!isUrlHealthEntryFresh(stored, url)) {
|
||
if (stored) {
|
||
removeUrlHealthFromStorage(eventId);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
urlHealthCache.set(eventId, stored);
|
||
return stored;
|
||
}
|
||
|
||
/**
|
||
* Stores a health result and calculates the expiry moment. The TTL lasts for
|
||
* roughly three quarters of an hour so we recover quickly when a CDN returns
|
||
* while still avoiding redundant probes during render thrash.
|
||
*/
|
||
function writeUrlHealthToCache(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS) {
|
||
if (!eventId) {
|
||
return null;
|
||
}
|
||
|
||
const ttl = typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : URL_HEALTH_TTL_MS;
|
||
const now = Date.now();
|
||
const entry = {
|
||
status: result?.status || "checking",
|
||
message: result?.message || "Checking hosted URL…",
|
||
url: url || result?.url || "",
|
||
expiresAt: now + ttl,
|
||
lastCheckedAt: now,
|
||
};
|
||
urlHealthCache.set(eventId, entry);
|
||
writeUrlHealthToStorage(eventId, entry);
|
||
return entry;
|
||
}
|
||
|
||
/**
|
||
* Tracks in-flight probe promises so that multiple cards rendering the same
|
||
* event simultaneously do not all fire off duplicate requests. The stored
|
||
* metadata includes the URL so edits invalidate the old request immediately.
|
||
*/
|
||
function buildUrlProbeKey(url, options = {}) {
|
||
const trimmed = typeof url === "string" ? url : "";
|
||
const mode = options?.confirmPlayable ? "playable" : "basic";
|
||
return `${trimmed}::${mode}`;
|
||
}
|
||
|
||
function setInFlightUrlProbe(eventId, url, promise, options = {}) {
|
||
if (!eventId || !promise) {
|
||
return;
|
||
}
|
||
|
||
const key = buildUrlProbeKey(url, options);
|
||
urlHealthInFlight.set(eventId, { promise, key });
|
||
promise.finally(() => {
|
||
const current = urlHealthInFlight.get(eventId);
|
||
if (current && current.promise === promise) {
|
||
urlHealthInFlight.delete(eventId);
|
||
}
|
||
});
|
||
}
|
||
|
||
function getInFlightUrlProbe(eventId, url, options = {}) {
|
||
if (!eventId) {
|
||
return null;
|
||
}
|
||
|
||
const entry = urlHealthInFlight.get(eventId);
|
||
if (!entry) {
|
||
return null;
|
||
}
|
||
|
||
const key = buildUrlProbeKey(url, options);
|
||
if (entry.key && entry.key !== key) {
|
||
return null;
|
||
}
|
||
|
||
return entry.promise;
|
||
}
|
||
// NOTE: The modal uses a "please stand by" animated poster while the torrent
|
||
// or direct URL boots up. We've regressed multiple times by forgetting to clear
|
||
// that poster once playback starts, which leaves the loading GIF covering the
|
||
// video even though audio is playing. Centralising the cleanup logic makes it
|
||
// easier to spot those regressions; see forceRemoveModalPoster() below.
|
||
const MODAL_LOADING_POSTER = "assets/gif/please-stand-by.gif";
|
||
|
||
/**
|
||
* Basic validation for BitTorrent magnet URIs.
|
||
*
|
||
* Returns `true` only when the value looks like a magnet link that WebTorrent
|
||
* understands (`magnet:` scheme with at least one `xt=urn:btih:<info-hash>`
|
||
* entry, where `<info-hash>` is either a 40-character hex digest or a
|
||
* 32-character base32 digest). Magnets that only contain BitTorrent v2 hashes
|
||
* (e.g. `btmh`) are treated as unsupported.
|
||
*/
|
||
function isValidMagnetUri(magnet) {
|
||
const trimmed = typeof magnet === "string" ? magnet.trim() : "";
|
||
if (!trimmed) {
|
||
return false;
|
||
}
|
||
|
||
const decoded = safeDecodeMagnet(trimmed);
|
||
const candidate = decoded || trimmed;
|
||
|
||
if (/^[0-9a-f]{40}$/i.test(candidate)) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(candidate);
|
||
if (parsed.protocol.toLowerCase() !== "magnet:") {
|
||
return false;
|
||
}
|
||
|
||
const xtValues = parsed.searchParams.getAll("xt");
|
||
if (!xtValues.length) {
|
||
return false;
|
||
}
|
||
|
||
const hexPattern = /^[0-9a-f]{40}$/i;
|
||
const base32Pattern = /^[A-Z2-7]{32}$/;
|
||
|
||
return xtValues.some((value) => {
|
||
if (typeof value !== "string") return false;
|
||
const match = value.trim().match(/^urn:btih:([a-z0-9]+)$/i);
|
||
if (!match) return false;
|
||
|
||
const infoHash = match[1];
|
||
if (hexPattern.test(infoHash)) {
|
||
return true;
|
||
}
|
||
|
||
const upperHash = infoHash.toUpperCase();
|
||
return infoHash.length === 32 && base32Pattern.test(upperHash);
|
||
});
|
||
} catch (err) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Simple IntersectionObserver-based lazy loader for images (or videos).
|
||
*
|
||
* Usage:
|
||
* const mediaLoader = new MediaLoader();
|
||
* mediaLoader.observe(imgElement);
|
||
*
|
||
* This will load the real image source from `imgElement.dataset.lazy`
|
||
* once the image enters the viewport.
|
||
*/
|
||
class MediaLoader {
|
||
constructor(rootMargin = "50px") {
|
||
this.observer = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) {
|
||
const el = entry.target;
|
||
const lazySrc =
|
||
typeof el.dataset.lazy === "string"
|
||
? el.dataset.lazy.trim()
|
||
: "";
|
||
|
||
if (lazySrc) {
|
||
const fallbackSrc =
|
||
(typeof el.dataset.fallbackSrc === "string"
|
||
? el.dataset.fallbackSrc.trim()
|
||
: "") ||
|
||
el.getAttribute("data-fallback-src") ||
|
||
"";
|
||
|
||
const applyFallback = () => {
|
||
const resolvedFallback =
|
||
fallbackSrc && fallbackSrc.trim()
|
||
? fallbackSrc.trim()
|
||
: FALLBACK_THUMBNAIL_SRC;
|
||
|
||
if (!resolvedFallback) {
|
||
return;
|
||
}
|
||
|
||
if (el.src !== resolvedFallback) {
|
||
el.src = resolvedFallback;
|
||
}
|
||
|
||
if (!el.dataset.fallbackSrc) {
|
||
el.dataset.fallbackSrc = resolvedFallback;
|
||
}
|
||
|
||
if (!el.getAttribute("data-fallback-src")) {
|
||
el.setAttribute("data-fallback-src", resolvedFallback);
|
||
}
|
||
|
||
el.dataset.thumbnailFailed = "true";
|
||
};
|
||
|
||
const cleanupListeners = () => {
|
||
el.removeEventListener("error", handleError);
|
||
el.removeEventListener("load", handleLoad);
|
||
};
|
||
|
||
const handleError = () => {
|
||
cleanupListeners();
|
||
applyFallback();
|
||
};
|
||
|
||
const handleLoad = () => {
|
||
cleanupListeners();
|
||
if (
|
||
(el.naturalWidth === 0 && el.naturalHeight === 0) ||
|
||
!el.currentSrc
|
||
) {
|
||
applyFallback();
|
||
} else {
|
||
delete el.dataset.thumbnailFailed;
|
||
}
|
||
};
|
||
|
||
el.addEventListener("error", handleError);
|
||
el.addEventListener("load", handleLoad);
|
||
|
||
if (fallbackSrc && lazySrc === fallbackSrc) {
|
||
cleanupListeners();
|
||
delete el.dataset.thumbnailFailed;
|
||
} else {
|
||
el.src = lazySrc;
|
||
|
||
if (el.complete) {
|
||
// Handle cached results synchronously
|
||
if (el.naturalWidth === 0 && el.naturalHeight === 0) {
|
||
handleError();
|
||
} else {
|
||
handleLoad();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
delete el.dataset.lazy;
|
||
// Stop observing once loaded
|
||
this.observer.unobserve(el);
|
||
}
|
||
}
|
||
},
|
||
{ rootMargin }
|
||
);
|
||
}
|
||
|
||
observe(el) {
|
||
if (!el || typeof el.dataset === "undefined") {
|
||
return;
|
||
}
|
||
|
||
if (!el.dataset.fallbackSrc) {
|
||
const fallbackAttr =
|
||
el.getAttribute("data-fallback-src") || el.getAttribute("src") || "";
|
||
if (fallbackAttr) {
|
||
el.dataset.fallbackSrc = fallbackAttr;
|
||
} else if (el.tagName === "IMG") {
|
||
el.dataset.fallbackSrc = FALLBACK_THUMBNAIL_SRC;
|
||
el.setAttribute("data-fallback-src", FALLBACK_THUMBNAIL_SRC);
|
||
}
|
||
}
|
||
|
||
if (
|
||
el.tagName === "IMG" &&
|
||
typeof HTMLImageElement !== "undefined" &&
|
||
"loading" in HTMLImageElement.prototype
|
||
) {
|
||
el.loading = el.loading || "lazy";
|
||
if ("decoding" in HTMLImageElement.prototype) {
|
||
el.decoding = el.decoding || "async";
|
||
}
|
||
}
|
||
|
||
const lazySrc =
|
||
typeof el.dataset.lazy === "string" ? el.dataset.lazy.trim() : "";
|
||
if (lazySrc) {
|
||
this.observer.observe(el);
|
||
}
|
||
}
|
||
|
||
disconnect() {
|
||
this.observer.disconnect();
|
||
}
|
||
}
|
||
|
||
class bitvidApp {
|
||
constructor() {
|
||
// Basic auth/display elements
|
||
this.loginButton = document.getElementById("loginButton") || null;
|
||
this.logoutButton = document.getElementById("logoutButton") || null;
|
||
this.userStatus = document.getElementById("userStatus") || null;
|
||
this.userPubKey = document.getElementById("userPubKey") || null;
|
||
|
||
// Lazy-loading helper for images
|
||
this.mediaLoader = new MediaLoader();
|
||
this.loadedThumbnails = new Map();
|
||
this.videoDiscussionCountCache = new Map();
|
||
this.inFlightDiscussionCounts = new Map();
|
||
this.activeIntervals = [];
|
||
this.urlPlaybackWatchdogCleanup = null;
|
||
|
||
// Optional: a "profile" button or avatar (if used)
|
||
this.profileButton = document.getElementById("profileButton") || null;
|
||
this.profileAvatar = document.getElementById("profileAvatar") || null;
|
||
|
||
// Profile modal references (if used in profile-modal.html)
|
||
this.profileModal = null;
|
||
this.closeProfileModal = null;
|
||
this.profileLogoutBtn = null;
|
||
this.profileModalAvatar = null;
|
||
this.profileModalName = null;
|
||
this.profileModalNpub = null;
|
||
this.profileChannelLink = null;
|
||
this.profileNavButtons = {
|
||
account: null,
|
||
relays: null,
|
||
blocked: null,
|
||
history: null,
|
||
admin: null,
|
||
};
|
||
this.profilePaneElements = {
|
||
account: null,
|
||
relays: null,
|
||
blocked: null,
|
||
history: null,
|
||
admin: null,
|
||
};
|
||
this.profileRelayList = null;
|
||
this.profileBlockedList = null;
|
||
this.profileBlockedEmpty = null;
|
||
this.profileBlockedInput = null;
|
||
this.profileAddBlockedBtn = null;
|
||
this.profileRelayInput = null;
|
||
this.profileAddRelayBtn = null;
|
||
this.profileRestoreRelaysBtn = null;
|
||
this.profileHistoryRenderer = null;
|
||
this.activeProfilePane = null;
|
||
this.adminModeratorInput = null;
|
||
this.adminAddModeratorBtn = null;
|
||
this.adminModeratorList = null;
|
||
this.adminModeratorsEmpty = null;
|
||
this.adminModeratorsSection = null;
|
||
this.adminWhitelistInput = null;
|
||
this.adminAddWhitelistBtn = null;
|
||
this.adminWhitelistList = null;
|
||
this.adminWhitelistEmpty = null;
|
||
this.adminWhitelistSection = null;
|
||
this.adminBlacklistInput = null;
|
||
this.adminAddBlacklistBtn = null;
|
||
this.adminBlacklistList = null;
|
||
this.adminBlacklistEmpty = null;
|
||
this.adminBlacklistSection = null;
|
||
this.lastFocusedBeforeProfileModal = null;
|
||
this.boundProfileModalKeydown = null;
|
||
this.boundProfileModalFocusIn = null;
|
||
this.profileModalFocusables = [];
|
||
this.profileSwitcherList = null;
|
||
this.profileAddAccountBtn = null;
|
||
this.profileSwitcherSelectionPubkey = null;
|
||
this.currentUserNpub = null;
|
||
|
||
// Upload modal elements
|
||
this.uploadButton = document.getElementById("uploadButton") || null;
|
||
this.uploadModal = document.getElementById("uploadModal") || null;
|
||
this.closeUploadModalBtn =
|
||
document.getElementById("closeUploadModal") || null;
|
||
this.uploadForm = document.getElementById("uploadForm") || null;
|
||
this.uploadEnableCommentsInput = null;
|
||
this.uploadModeToggleButtons = [];
|
||
this.customUploadSection = null;
|
||
this.cloudflareUploadSection = null;
|
||
this.activeUploadMode = "custom";
|
||
this.cloudflareSettings = null;
|
||
this.cloudflareSettingsForm = null;
|
||
this.cloudflareClearSettingsButton = null;
|
||
this.cloudflareSettingsStatus = null;
|
||
this.cloudflareBucketPreview = null;
|
||
this.cloudflareUploadForm = null;
|
||
this.cloudflareFileInput = null;
|
||
this.cloudflareUploadButton = null;
|
||
this.cloudflareUploadStatus = null;
|
||
this.cloudflareProgressBar = null;
|
||
this.cloudflareProgressFill = null;
|
||
this.cloudflareTitleInput = null;
|
||
this.cloudflareDescriptionInput = null;
|
||
this.cloudflareThumbnailInput = null;
|
||
this.cloudflareMagnetInput = null;
|
||
this.cloudflareWsInput = null;
|
||
this.cloudflareXsInput = null;
|
||
this.cloudflareEnableCommentsInput = null;
|
||
this.cloudflareAdvancedToggle = null;
|
||
this.cloudflareAdvancedToggleLabel = null;
|
||
this.cloudflareAdvancedToggleIcon = null;
|
||
this.cloudflareAdvancedFields = null;
|
||
this.cloudflareAdvancedVisible = false;
|
||
this.r2AccountIdInput = null;
|
||
this.r2AccessKeyIdInput = null;
|
||
this.r2SecretAccessKeyInput = null;
|
||
this.r2ApiTokenInput = null;
|
||
this.r2ZoneIdInput = null;
|
||
this.r2BaseDomainInput = null;
|
||
|
||
// Edit video modal elements
|
||
this.editVideoModal = null;
|
||
this.editVideoOverlay = null;
|
||
this.editVideoForm = null;
|
||
this.closeEditVideoModalBtn = null;
|
||
this.cancelEditVideoBtn = null;
|
||
this.editVideoSubmitBtn = null;
|
||
this.editVideoFieldButtons = [];
|
||
this.activeEditVideo = null;
|
||
|
||
// Revert video modal elements
|
||
this.revertVideoModal = null;
|
||
this.revertVideoOverlay = null;
|
||
this.revertVersionsList = null;
|
||
this.revertVersionDetails = null;
|
||
this.revertVersionPlaceholder = null;
|
||
this.revertVersionDetailsDefaultHTML = "";
|
||
this.revertHistoryCount = null;
|
||
this.revertSelectionStatus = null;
|
||
this.revertModalTitle = null;
|
||
this.revertModalSubtitle = null;
|
||
this.closeRevertVideoModalBtn = null;
|
||
this.cancelRevertVideoBtn = null;
|
||
this.confirmRevertVideoBtn = null;
|
||
this.activeRevertVideo = null;
|
||
this.revertHistory = [];
|
||
this.selectedRevertTarget = null;
|
||
this.revertModalBusy = false;
|
||
this.revertConfirmDefaultLabel = "Revert to selected version";
|
||
this.pendingRevertEntries = [];
|
||
|
||
// Optional small inline player stats
|
||
this.status = document.getElementById("status") || null;
|
||
this.progressBar = document.getElementById("progress") || null;
|
||
this.peers = document.getElementById("peers") || null;
|
||
this.speed = document.getElementById("speed") || null;
|
||
this.downloaded = document.getElementById("downloaded") || null;
|
||
|
||
// Video player modal references (loaded via video-modal.html)
|
||
this.playerModal = null;
|
||
this.modalVideo = null;
|
||
this.modalStatus = null;
|
||
this.modalProgress = null;
|
||
this.modalPeers = null;
|
||
this.modalSpeed = null;
|
||
this.modalDownloaded = null;
|
||
this.closePlayerBtn = null;
|
||
this.videoTitle = null;
|
||
this.videoDescription = null;
|
||
this.videoTimestamp = null;
|
||
this.creatorAvatar = null;
|
||
this.creatorName = null;
|
||
this.creatorNpub = null;
|
||
this.copyMagnetBtn = null;
|
||
this.shareBtn = null;
|
||
this.modalZapBtn = null;
|
||
this.modalMoreBtn = null;
|
||
this.modalMoreMenu = null;
|
||
this.modalPosterCleanup = null;
|
||
this.cleanupPromise = null;
|
||
|
||
// Hide/Show Subscriptions Link
|
||
this.subscriptionsLink = null;
|
||
|
||
// Notification containers
|
||
this.errorContainer = document.getElementById("errorContainer") || null;
|
||
this.successContainer = document.getElementById("successContainer") || null;
|
||
this.statusContainer = document.getElementById("statusContainer") || null;
|
||
this.statusMessage =
|
||
this.statusContainer?.querySelector("[data-status-message]") || null;
|
||
|
||
// Auth state
|
||
this.pubkey = null;
|
||
this.currentMagnetUri = null;
|
||
this.currentVideo = null;
|
||
this.currentVideoPointer = null;
|
||
this.currentVideoPointerKey = null;
|
||
this.playbackTelemetryState = null;
|
||
this.loggedViewPointerKeys = new Set();
|
||
this.videoSubscription = null;
|
||
this.videoList = null;
|
||
this._videoListElement = null;
|
||
this._videoListClickHandler = null;
|
||
this.viewCountSubscriptions = new Map();
|
||
this.modalViewCountUnsub = null;
|
||
this.videoViewCountEl = null;
|
||
|
||
// Videos stored as a Map (key=event.id)
|
||
this.videosMap = new Map();
|
||
this.moreMenuGlobalHandlerBound = false;
|
||
this.boundMoreMenuDocumentClick = null;
|
||
this.boundMoreMenuDocumentKeydown = null;
|
||
// Simple cache for user profiles
|
||
this.profileCache = new Map();
|
||
this.savedProfiles = [];
|
||
this.activeProfilePubkey = null;
|
||
this.readSavedProfilesPayloadFromStorage = () => {
|
||
if (typeof localStorage === "undefined") {
|
||
return null;
|
||
}
|
||
|
||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||
if (!raw) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || typeof parsed !== "object") {
|
||
return null;
|
||
}
|
||
return parsed;
|
||
} catch (err) {
|
||
console.warn("[readSavedProfilesPayloadFromStorage] Failed to parse payload:", err);
|
||
return null;
|
||
}
|
||
};
|
||
this.writeSavedProfilesPayloadToStorage = (payload) => {
|
||
if (typeof localStorage === "undefined") {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
localStorage.setItem(
|
||
SAVED_PROFILES_STORAGE_KEY,
|
||
JSON.stringify(payload)
|
||
);
|
||
} catch (err) {
|
||
const isQuotaError =
|
||
err &&
|
||
(err.name === "QuotaExceededError" ||
|
||
err.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||
err.code === 22 ||
|
||
err.code === 1014);
|
||
if (isQuotaError) {
|
||
console.warn(
|
||
"[writeSavedProfilesPayloadToStorage] Storage quota exceeded while saving profiles; keeping in-memory copy only.",
|
||
err
|
||
);
|
||
} else {
|
||
console.warn(
|
||
"[writeSavedProfilesPayloadToStorage] Failed to persist saved profiles:",
|
||
err
|
||
);
|
||
}
|
||
}
|
||
};
|
||
this.persistSavedProfiles = ({ persistActive = true } = {}) => {
|
||
if (typeof localStorage === "undefined") {
|
||
return;
|
||
}
|
||
|
||
if (!this.savedProfiles.length && !this.activeProfilePubkey) {
|
||
try {
|
||
localStorage.removeItem(SAVED_PROFILES_STORAGE_KEY);
|
||
} catch (err) {
|
||
console.warn("[persistSavedProfiles] Failed to remove empty payload:", err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let activePubkeyToPersist = this.activeProfilePubkey || null;
|
||
if (!persistActive) {
|
||
const storedPayload = this.readSavedProfilesPayloadFromStorage();
|
||
if (storedPayload && typeof storedPayload === "object") {
|
||
const candidate =
|
||
typeof storedPayload.activePubkey === "string"
|
||
? storedPayload.activePubkey
|
||
: typeof storedPayload.activePubKey === "string"
|
||
? storedPayload.activePubKey
|
||
: null;
|
||
const normalizedStored = this.normalizeHexPubkey(candidate);
|
||
if (normalizedStored) {
|
||
activePubkeyToPersist = normalizedStored;
|
||
} else if (candidate === null) {
|
||
activePubkeyToPersist = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
const payload = {
|
||
version: SAVED_PROFILES_STORAGE_VERSION,
|
||
entries: this.savedProfiles.map((entry) => ({
|
||
pubkey: entry.pubkey,
|
||
npub:
|
||
typeof entry.npub === "string" && entry.npub.trim()
|
||
? entry.npub.trim()
|
||
: null,
|
||
name: typeof entry.name === "string" ? entry.name : "",
|
||
picture:
|
||
typeof entry.picture === "string" ? entry.picture : "",
|
||
authType: entry.authType === "nsec" ? "nsec" : "nip07",
|
||
})),
|
||
activePubkey: activePubkeyToPersist,
|
||
};
|
||
|
||
this.writeSavedProfilesPayloadToStorage(payload);
|
||
};
|
||
this.persistActiveProfileSelection = (
|
||
pubkey,
|
||
{ persist = true } = {}
|
||
) => {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
this.activeProfilePubkey = normalized;
|
||
if (persist) {
|
||
this.persistSavedProfiles({ persistActive: true });
|
||
}
|
||
this.renderSavedProfiles();
|
||
};
|
||
this.lastRenderedVideoSignature = null;
|
||
this._lastRenderedVideoListElement = null;
|
||
this.renderedVideoIds = new Set();
|
||
|
||
// NEW: reference to the login modal's close button
|
||
this.closeLoginModalBtn =
|
||
document.getElementById("closeLoginModal") || null;
|
||
|
||
// Build a set of blacklisted event IDs (hex) from nevent strings, skipping empties
|
||
this.blacklistedEventIds = new Set();
|
||
for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) {
|
||
// Skip any empty or obviously invalid strings
|
||
if (!neventStr || neventStr.trim().length < 8) {
|
||
continue;
|
||
}
|
||
try {
|
||
const decoded = window.NostrTools.nip19.decode(neventStr);
|
||
if (decoded.type === "nevent" && decoded.data.id) {
|
||
this.blacklistedEventIds.add(decoded.data.id);
|
||
}
|
||
} catch (err) {
|
||
console.error(
|
||
"[bitvidApp] Invalid nevent in blacklist:",
|
||
neventStr,
|
||
err
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
loadSavedProfilesFromStorage() {
|
||
this.savedProfiles = [];
|
||
this.activeProfilePubkey = null;
|
||
|
||
if (typeof localStorage === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const seenPubkeys = new Set();
|
||
let needsRewrite = false;
|
||
let parsed = null;
|
||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||
if (raw) {
|
||
try {
|
||
parsed = JSON.parse(raw);
|
||
} catch (err) {
|
||
console.warn("Failed to parse saved profiles payload:", err);
|
||
needsRewrite = true;
|
||
}
|
||
}
|
||
|
||
if (
|
||
parsed &&
|
||
typeof parsed === "object" &&
|
||
parsed.version === SAVED_PROFILES_STORAGE_VERSION &&
|
||
Array.isArray(parsed.entries)
|
||
) {
|
||
for (const candidate of parsed.entries) {
|
||
if (!candidate || typeof candidate !== "object") {
|
||
needsRewrite = true;
|
||
continue;
|
||
}
|
||
|
||
const normalizedPubkey = this.normalizeHexPubkey(candidate.pubkey);
|
||
if (!normalizedPubkey) {
|
||
needsRewrite = true;
|
||
continue;
|
||
}
|
||
|
||
if (seenPubkeys.has(normalizedPubkey)) {
|
||
needsRewrite = true;
|
||
continue;
|
||
}
|
||
|
||
seenPubkeys.add(normalizedPubkey);
|
||
|
||
const storedAuthType =
|
||
candidate.authType === "nsec" ? "nsec" : "nip07";
|
||
if (candidate.authType !== storedAuthType) {
|
||
needsRewrite = true;
|
||
}
|
||
|
||
const entry = {
|
||
pubkey: normalizedPubkey,
|
||
npub:
|
||
typeof candidate.npub === "string" && candidate.npub.trim()
|
||
? candidate.npub.trim()
|
||
: this.safeEncodeNpub(normalizedPubkey),
|
||
name:
|
||
typeof candidate.name === "string" ? candidate.name : "",
|
||
picture:
|
||
typeof candidate.picture === "string" ? candidate.picture : "",
|
||
authType: storedAuthType,
|
||
};
|
||
|
||
if (
|
||
typeof candidate.npub === "string" &&
|
||
candidate.npub.trim() !== candidate.npub
|
||
) {
|
||
needsRewrite = true;
|
||
}
|
||
|
||
if (
|
||
entry.npub &&
|
||
typeof candidate.npub !== "string" &&
|
||
entry.npub !== candidate.npub
|
||
) {
|
||
needsRewrite = true;
|
||
}
|
||
|
||
this.savedProfiles.push(entry);
|
||
}
|
||
|
||
const activeCandidate =
|
||
typeof parsed.activePubkey === "string"
|
||
? parsed.activePubkey
|
||
: typeof parsed.activePubKey === "string"
|
||
? parsed.activePubKey
|
||
: null;
|
||
const normalizedActive = this.normalizeHexPubkey(activeCandidate);
|
||
if (normalizedActive && seenPubkeys.has(normalizedActive)) {
|
||
this.activeProfilePubkey = normalizedActive;
|
||
} else if (activeCandidate) {
|
||
needsRewrite = true;
|
||
}
|
||
} else if (raw) {
|
||
needsRewrite = true;
|
||
}
|
||
|
||
if (!this.activeProfilePubkey && this.savedProfiles.length) {
|
||
this.activeProfilePubkey = this.savedProfiles[0].pubkey;
|
||
needsRewrite = true;
|
||
}
|
||
|
||
if (needsRewrite) {
|
||
this.persistSavedProfiles();
|
||
}
|
||
|
||
this.renderSavedProfiles();
|
||
}
|
||
|
||
syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
|
||
const cacheEntry = this.profileCache.get(normalized);
|
||
if (!cacheEntry || typeof cacheEntry !== "object") {
|
||
return false;
|
||
}
|
||
|
||
const index = this.savedProfiles.findIndex(
|
||
(entry) => entry && entry.pubkey === normalized
|
||
);
|
||
if (index < 0) {
|
||
return false;
|
||
}
|
||
|
||
const existing = this.savedProfiles[index] || {};
|
||
const profile = cacheEntry.profile || {};
|
||
const nextEntry = {
|
||
...existing,
|
||
name: profile.name || existing.name || "",
|
||
picture: profile.picture || existing.picture || "",
|
||
};
|
||
|
||
const changed =
|
||
existing.name !== nextEntry.name || existing.picture !== nextEntry.picture;
|
||
|
||
if (!changed) {
|
||
return false;
|
||
}
|
||
|
||
this.savedProfiles[index] = nextEntry;
|
||
|
||
if (persist) {
|
||
this.persistSavedProfiles({ persistActive: false });
|
||
}
|
||
|
||
this.renderSavedProfiles();
|
||
return true;
|
||
}
|
||
|
||
loadProfileCacheFromStorage() {
|
||
if (typeof localStorage === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
const raw = localStorage.getItem(PROFILE_CACHE_STORAGE_KEY);
|
||
if (!raw) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || typeof parsed !== "object") {
|
||
return;
|
||
}
|
||
|
||
if (parsed.version !== PROFILE_CACHE_VERSION) {
|
||
return;
|
||
}
|
||
|
||
const entries = parsed.entries;
|
||
if (!entries || typeof entries !== "object") {
|
||
return;
|
||
}
|
||
|
||
for (const [pubkey, entry] of Object.entries(entries)) {
|
||
if (!pubkey || !entry || typeof entry !== "object") {
|
||
continue;
|
||
}
|
||
|
||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||
continue;
|
||
}
|
||
|
||
const profile = entry.profile;
|
||
if (!profile || typeof profile !== "object") {
|
||
continue;
|
||
}
|
||
|
||
const normalized = {
|
||
name: profile.name || profile.display_name || "Unknown",
|
||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||
};
|
||
|
||
this.profileCache.set(pubkey, {
|
||
profile: normalized,
|
||
timestamp,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.warn("Failed to parse stored profile cache:", err);
|
||
}
|
||
}
|
||
|
||
persistProfileCacheToStorage() {
|
||
if (typeof localStorage === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
const entries = {};
|
||
|
||
for (const [pubkey, entry] of this.profileCache.entries()) {
|
||
if (!entry || typeof entry !== "object") {
|
||
continue;
|
||
}
|
||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
|
||
this.profileCache.delete(pubkey);
|
||
continue;
|
||
}
|
||
|
||
entries[pubkey] = {
|
||
profile: entry.profile,
|
||
timestamp,
|
||
};
|
||
}
|
||
|
||
const payload = {
|
||
version: PROFILE_CACHE_VERSION,
|
||
savedAt: now,
|
||
entries,
|
||
};
|
||
|
||
if (Object.keys(entries).length === 0) {
|
||
try {
|
||
localStorage.removeItem(PROFILE_CACHE_STORAGE_KEY);
|
||
} catch (err) {
|
||
console.warn("Failed to clear profile cache storage:", err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
localStorage.setItem(PROFILE_CACHE_STORAGE_KEY, JSON.stringify(payload));
|
||
} catch (err) {
|
||
console.warn("Failed to persist profile cache:", err);
|
||
}
|
||
}
|
||
|
||
getProfileCacheEntry(pubkey) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
|
||
const entry = this.profileCache.get(normalized);
|
||
if (!entry) {
|
||
return null;
|
||
}
|
||
|
||
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
|
||
if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) {
|
||
this.profileCache.delete(normalized);
|
||
this.persistProfileCacheToStorage();
|
||
return null;
|
||
}
|
||
|
||
return entry;
|
||
}
|
||
|
||
setProfileCacheEntry(pubkey, profile) {
|
||
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
|
||
if (!normalizedPubkey || !profile) {
|
||
return;
|
||
}
|
||
|
||
const normalized = {
|
||
name: profile.name || profile.display_name || "Unknown",
|
||
picture: profile.picture || "assets/svg/default-profile.svg",
|
||
};
|
||
|
||
const entry = {
|
||
profile: normalized,
|
||
timestamp: Date.now(),
|
||
};
|
||
|
||
this.profileCache.set(normalizedPubkey, entry);
|
||
this.persistProfileCacheToStorage();
|
||
const didUpdateSavedProfile = this.syncSavedProfileFromCache(normalizedPubkey, {
|
||
persist: true,
|
||
});
|
||
if (!didUpdateSavedProfile) {
|
||
this.renderSavedProfiles();
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
prepareForViewLoad() {
|
||
if (this._videoListElement && this._videoListClickHandler) {
|
||
try {
|
||
this._videoListElement.removeEventListener(
|
||
"click",
|
||
this._videoListClickHandler
|
||
);
|
||
} catch (err) {
|
||
console.warn("[prepareForViewLoad] Failed to detach video list handler:", err);
|
||
}
|
||
this._videoListElement = null;
|
||
this._videoListClickHandler = null;
|
||
}
|
||
|
||
this.videoList = null;
|
||
this._lastRenderedVideoListElement = null;
|
||
this.lastRenderedVideoSignature = null;
|
||
if (this.renderedVideoIds) {
|
||
this.renderedVideoIds.clear();
|
||
}
|
||
|
||
if (this.mediaLoader && typeof this.mediaLoader.disconnect === "function") {
|
||
this.mediaLoader.disconnect();
|
||
}
|
||
}
|
||
|
||
forceRefreshAllProfiles() {
|
||
// 1) Grab the newest set of videos from nostrClient
|
||
const activeVideos = nostrClient.getActiveVideos();
|
||
|
||
// 2) Build a unique set of pubkeys
|
||
const uniqueAuthors = new Set(activeVideos.map((v) => v.pubkey));
|
||
|
||
// 3) For each author, fetchAndRenderProfile with forceRefresh = true
|
||
for (const authorPubkey of uniqueAuthors) {
|
||
this.fetchAndRenderProfile(authorPubkey, true);
|
||
}
|
||
}
|
||
|
||
async init() {
|
||
try {
|
||
// Force update of any registered service workers to ensure latest code is used.
|
||
if ("serviceWorker" in navigator) {
|
||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||
registrations.forEach((registration) => registration.update());
|
||
});
|
||
}
|
||
|
||
this.loadProfileCacheFromStorage();
|
||
this.loadSavedProfilesFromStorage();
|
||
|
||
// 1. Initialize the video modal (components/video-modal.html)
|
||
await this.initModal();
|
||
this.updateModalElements();
|
||
|
||
// 2. Initialize the upload modal (components/upload-modal.html)
|
||
await this.initUploadModal();
|
||
initQuickR2Upload(this);
|
||
|
||
// 2.5 Initialize the edit modal (components/edit-video-modal.html)
|
||
await this.initEditVideoModal();
|
||
|
||
// 3. (Optional) Initialize the profile modal (components/profile-modal.html)
|
||
await this.initProfileModal();
|
||
|
||
// 4. Connect to Nostr
|
||
await nostrClient.init();
|
||
|
||
try {
|
||
initViewCounter({ nostrClient });
|
||
} catch (error) {
|
||
console.warn("Failed to initialize view counter:", error);
|
||
}
|
||
|
||
try {
|
||
await accessControl.refresh();
|
||
} catch (error) {
|
||
console.warn("Failed to refresh admin lists after connecting to Nostr:", error);
|
||
}
|
||
|
||
try {
|
||
await this.refreshAdminPaneState();
|
||
} catch (error) {
|
||
console.warn(
|
||
"Failed to update admin pane after connecting to Nostr:",
|
||
error
|
||
);
|
||
}
|
||
|
||
// Grab the "Subscriptions" link by its id in the sidebar
|
||
this.subscriptionsLink = document.getElementById("subscriptionsLink");
|
||
|
||
const savedPubKey = this.activeProfilePubkey;
|
||
if (savedPubKey) {
|
||
// Auto-login if a pubkey was saved
|
||
try {
|
||
await this.login(savedPubKey, { persistActive: false });
|
||
} catch (error) {
|
||
console.error("Auto-login failed:", error);
|
||
}
|
||
|
||
// If the user was already logged in, show the Subscriptions link
|
||
if (this.subscriptionsLink) {
|
||
this.subscriptionsLink.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
// 5. Setup general event listeners
|
||
this.setupEventListeners();
|
||
|
||
// 6) Load the default view ONLY if there's no #view= already
|
||
if (!window.location.hash || !window.location.hash.startsWith("#view=")) {
|
||
console.log(
|
||
"[app.init()] No #view= in the URL, loading default home view"
|
||
);
|
||
await loadView("views/most-recent-videos.html");
|
||
} else {
|
||
console.log(
|
||
"[app.init()] Found hash:",
|
||
window.location.hash,
|
||
"so skipping default load"
|
||
);
|
||
}
|
||
|
||
// 7. Once loaded, get a reference to #videoList
|
||
this.videoList = document.getElementById("videoList");
|
||
this.attachVideoListHandler();
|
||
|
||
// 8. Subscribe or fetch videos
|
||
await this.loadVideos();
|
||
|
||
// 9. Check URL ?v= param
|
||
this.checkUrlParams();
|
||
|
||
} catch (error) {
|
||
console.error("Init failed:", error);
|
||
this.showError("Failed to connect to Nostr relay");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize the main video modal (video-modal.html).
|
||
*/
|
||
async initModal() {
|
||
try {
|
||
const existingModal = document.getElementById("playerModal");
|
||
if (existingModal) {
|
||
this.playerModal = existingModal;
|
||
return true;
|
||
}
|
||
|
||
const resp = await fetch("components/video-modal.html");
|
||
if (!resp.ok) {
|
||
throw new Error(`HTTP error! status: ${resp.status}`);
|
||
}
|
||
const html = await resp.text();
|
||
|
||
const modalContainer = document.getElementById("modalContainer");
|
||
if (!modalContainer) {
|
||
throw new Error("Modal container element not found!");
|
||
}
|
||
|
||
// Instead of overwriting, we append a new DIV with the fetched HTML
|
||
const wrapper = document.createElement("div");
|
||
wrapper.innerHTML = html; // set the markup
|
||
removeTrackingScripts(wrapper);
|
||
modalContainer.appendChild(wrapper); // append the markup
|
||
|
||
// Now we can safely find elements inside:
|
||
const closeButton = document.getElementById("closeModal");
|
||
if (!closeButton) {
|
||
throw new Error("Close button not found in video-modal!");
|
||
}
|
||
closeButton.addEventListener("click", () => {
|
||
this.hideModal();
|
||
});
|
||
|
||
// Setup scroll-based nav hide
|
||
const modalNav = document.getElementById("modalNav");
|
||
const playerModal = document.getElementById("playerModal");
|
||
if (!modalNav || !playerModal) {
|
||
throw new Error("Modal nav (#modalNav) or #playerModal not found!");
|
||
}
|
||
const scrollRegion =
|
||
playerModal.querySelector(".player-modal__content") || playerModal;
|
||
let lastScrollY = 0;
|
||
scrollRegion.addEventListener("scroll", () => {
|
||
const currentScrollY = scrollRegion.scrollTop;
|
||
const shouldShowNav =
|
||
currentScrollY <= lastScrollY || currentScrollY < 50;
|
||
modalNav.style.transform = shouldShowNav
|
||
? "translateY(0)"
|
||
: "translateY(-100%)";
|
||
lastScrollY = currentScrollY;
|
||
});
|
||
|
||
this.videoViewCountEl =
|
||
document.getElementById("videoViewCount") || null;
|
||
|
||
console.log("Video modal initialization successful");
|
||
return true;
|
||
} catch (error) {
|
||
console.error("initModal failed:", error);
|
||
this.showError(`Failed to initialize video modal: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
updateModalElements() {
|
||
// Existing references
|
||
this.playerModal = document.getElementById("playerModal") || null;
|
||
this.modalVideo = document.getElementById("modalVideo") || null;
|
||
this.modalStatus = document.getElementById("modalStatus") || null;
|
||
this.modalProgress = document.getElementById("modalProgress") || null;
|
||
this.modalPeers = document.getElementById("modalPeers") || null;
|
||
this.modalSpeed = document.getElementById("modalSpeed") || null;
|
||
this.modalDownloaded = document.getElementById("modalDownloaded") || null;
|
||
this.closePlayerBtn = document.getElementById("closeModal") || null;
|
||
|
||
this.videoTitle = document.getElementById("videoTitle") || null;
|
||
this.videoDescription = document.getElementById("videoDescription") || null;
|
||
this.videoTimestamp = document.getElementById("videoTimestamp") || null;
|
||
this.videoViewCountEl =
|
||
document.getElementById("videoViewCount") || null;
|
||
|
||
// The two elements we want to make clickable
|
||
this.creatorAvatar = document.getElementById("creatorAvatar") || null;
|
||
this.creatorName = document.getElementById("creatorName") || null;
|
||
this.creatorNpub = document.getElementById("creatorNpub") || null;
|
||
|
||
// Copy/Share buttons
|
||
this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null;
|
||
this.shareBtn = document.getElementById("shareBtn") || null;
|
||
this.modalZapBtn = document.getElementById("modalZapBtn") || null;
|
||
this.modalMoreBtn = document.getElementById("modalMoreBtn") || null;
|
||
this.modalMoreMenu = document.getElementById("moreDropdown-modal") || null;
|
||
this.setModalZapVisibility(false);
|
||
|
||
// Attach existing event listeners for copy/share
|
||
if (this.copyMagnetBtn) {
|
||
this.copyMagnetBtn.addEventListener("click", () => {
|
||
this.handleCopyMagnet();
|
||
});
|
||
}
|
||
if (this.shareBtn) {
|
||
this.shareBtn.addEventListener("click", () => {
|
||
if (!this.currentVideo || !this.currentVideo.id) {
|
||
this.showError("No shareable video is loaded.");
|
||
return;
|
||
}
|
||
const shareUrl = this.buildShareUrlFromEventId(this.currentVideo.id);
|
||
if (!shareUrl) {
|
||
this.showError("Could not generate link.");
|
||
return;
|
||
}
|
||
navigator.clipboard
|
||
.writeText(shareUrl)
|
||
.then(() => this.showSuccess("Video link copied to clipboard!"))
|
||
.catch(() => this.showError("Failed to copy the link."));
|
||
});
|
||
}
|
||
if (this.modalZapBtn) {
|
||
this.modalZapBtn.addEventListener("click", () => {
|
||
window.alert("Zaps coming soon.");
|
||
});
|
||
}
|
||
|
||
// Add click handlers for avatar and name => channel profile
|
||
if (this.creatorAvatar) {
|
||
this.creatorAvatar.style.cursor = "pointer";
|
||
this.creatorAvatar.addEventListener("click", () => {
|
||
this.openCreatorChannel();
|
||
});
|
||
}
|
||
if (this.creatorName) {
|
||
this.creatorName.style.cursor = "pointer";
|
||
this.creatorName.addEventListener("click", () => {
|
||
this.openCreatorChannel();
|
||
});
|
||
}
|
||
|
||
if (this.playerModal) {
|
||
this.attachMoreMenuHandlers(this.playerModal);
|
||
}
|
||
|
||
this.syncModalMoreMenuData();
|
||
}
|
||
|
||
goToProfile(pubkey) {
|
||
if (!pubkey) {
|
||
this.showError("No creator info available.");
|
||
return;
|
||
}
|
||
try {
|
||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||
// Switch to channel profile view
|
||
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||
} catch (err) {
|
||
console.error("Failed to go to channel:", err);
|
||
this.showError("Could not open channel.");
|
||
}
|
||
}
|
||
|
||
openCreatorChannel() {
|
||
if (!this.currentVideo || !this.currentVideo.pubkey) {
|
||
this.showError("No creator info available.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Encode the hex pubkey to npub
|
||
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
|
||
|
||
// Close the video modal
|
||
this.hideModal();
|
||
|
||
// Switch to channel profile view
|
||
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||
} catch (err) {
|
||
console.error("Failed to open creator channel:", err);
|
||
this.showError("Could not open channel.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show the modal and set the "Please stand by" poster on the video.
|
||
*/
|
||
showModalWithPoster() {
|
||
if (this.playerModal) {
|
||
this.playerModal.style.display = "flex";
|
||
this.playerModal.classList.remove("hidden");
|
||
document.body.classList.add("modal-open");
|
||
document.documentElement.classList.add("modal-open");
|
||
const scrollRegion =
|
||
this.playerModal.querySelector(".player-modal__content") ||
|
||
this.playerModal;
|
||
scrollRegion.scrollTop = 0;
|
||
}
|
||
this.applyModalLoadingPoster();
|
||
}
|
||
|
||
applyModalLoadingPoster() {
|
||
if (!this.modalVideo) {
|
||
return;
|
||
}
|
||
|
||
if (typeof this.modalPosterCleanup === "function") {
|
||
this.modalPosterCleanup();
|
||
this.modalPosterCleanup = null;
|
||
}
|
||
|
||
const videoEl = this.modalVideo;
|
||
|
||
const clearPoster = () => {
|
||
// Delegate to the shared helper so every code path clears the GIF in the
|
||
// same way. If we ever change how the poster works we only need to update
|
||
// forceRemoveModalPoster().
|
||
this.forceRemoveModalPoster("playback-event");
|
||
};
|
||
|
||
videoEl.addEventListener("loadeddata", clearPoster);
|
||
videoEl.addEventListener("playing", clearPoster);
|
||
|
||
videoEl.poster = MODAL_LOADING_POSTER;
|
||
|
||
this.modalPosterCleanup = () => {
|
||
videoEl.removeEventListener("loadeddata", clearPoster);
|
||
videoEl.removeEventListener("playing", clearPoster);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Forcefully strip the modal's loading poster.
|
||
*
|
||
* The video modal shows an animated "please stand by" GIF while we wait for a
|
||
* `loadeddata` or `playing` event. Historically, small refactors would remove
|
||
* the matching cleanup path which left the GIF covering real playback. By
|
||
* routing every caller through this helper we can add defensive clears (for
|
||
* example when WebTorrent reports progress) without duplicating poster logic
|
||
* all over the file.
|
||
*
|
||
* @param {string} reason A debugging hint that documents why the poster was
|
||
* cleared.
|
||
* @returns {boolean} `true` if a poster attribute/value was removed.
|
||
*/
|
||
forceRemoveModalPoster(reason = "manual-clear") {
|
||
if (!this.modalVideo) {
|
||
return false;
|
||
}
|
||
|
||
const videoEl = this.modalVideo;
|
||
|
||
if (typeof this.modalPosterCleanup === "function") {
|
||
this.modalPosterCleanup();
|
||
this.modalPosterCleanup = null;
|
||
}
|
||
|
||
const hadPoster =
|
||
videoEl.hasAttribute("poster") ||
|
||
(typeof videoEl.poster === "string" && videoEl.poster !== "");
|
||
|
||
if (!hadPoster) {
|
||
return false;
|
||
}
|
||
|
||
videoEl.poster = "";
|
||
if (videoEl.hasAttribute("poster")) {
|
||
videoEl.removeAttribute("poster");
|
||
}
|
||
|
||
console.debug(
|
||
`[bitvidApp] Cleared modal loading poster (${reason}).`
|
||
);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Initialize the upload modal (upload-modal.html).
|
||
*/
|
||
async initUploadModal() {
|
||
try {
|
||
const resp = await fetch("components/upload-modal.html");
|
||
if (!resp.ok) {
|
||
throw new Error(`HTTP error! status: ${resp.status}`);
|
||
}
|
||
const html = await resp.text();
|
||
|
||
const modalContainer = document.getElementById("modalContainer");
|
||
if (!modalContainer) {
|
||
throw new Error("Modal container element not found!");
|
||
}
|
||
// Append the upload modal markup
|
||
const wrapper = document.createElement("div");
|
||
wrapper.innerHTML = html;
|
||
removeTrackingScripts(wrapper);
|
||
modalContainer.appendChild(wrapper);
|
||
|
||
// Grab references
|
||
this.uploadModal = document.getElementById("uploadModal") || null;
|
||
this.closeUploadModalBtn =
|
||
document.getElementById("closeUploadModal") || null;
|
||
this.uploadForm = document.getElementById("uploadForm") || null;
|
||
this.uploadEnableCommentsInput =
|
||
document.getElementById("uploadEnableComments") || null;
|
||
this.uploadModeToggleButtons = Array.from(
|
||
document.querySelectorAll(".upload-mode-toggle[data-upload-mode]")
|
||
);
|
||
this.customUploadSection =
|
||
document.getElementById("customUploadSection") || null;
|
||
this.cloudflareUploadSection =
|
||
document.getElementById("cloudflareUploadSection") || null;
|
||
this.cloudflareSettingsForm =
|
||
document.getElementById("cloudflareSettingsForm") || null;
|
||
this.cloudflareClearSettingsButton =
|
||
document.getElementById("cloudflareClearSettings") || null;
|
||
this.cloudflareSettingsStatus =
|
||
document.getElementById("cloudflareSettingsStatus") || null;
|
||
this.cloudflareBucketPreview =
|
||
document.getElementById("cloudflareBucketPreview") || null;
|
||
this.cloudflareUploadForm =
|
||
document.getElementById("cloudflareUploadForm") || null;
|
||
this.cloudflareFileInput =
|
||
document.getElementById("cloudflareFile") || null;
|
||
this.cloudflareUploadButton =
|
||
document.getElementById("cloudflareUploadButton") || null;
|
||
this.cloudflareUploadStatus =
|
||
document.getElementById("cloudflareUploadStatus") || null;
|
||
this.cloudflareProgressBar =
|
||
document.getElementById("cloudflareProgressBar") || null;
|
||
this.cloudflareProgressFill =
|
||
document.getElementById("cloudflareProgressFill") || null;
|
||
this.cloudflareTitleInput =
|
||
document.getElementById("cloudflareTitle") || null;
|
||
this.cloudflareDescriptionInput =
|
||
document.getElementById("cloudflareDescription") || null;
|
||
this.cloudflareThumbnailInput =
|
||
document.getElementById("cloudflareThumbnail") || null;
|
||
this.cloudflareMagnetInput =
|
||
document.getElementById("cloudflareMagnet") || null;
|
||
this.cloudflareWsInput =
|
||
document.getElementById("cloudflareWs") || null;
|
||
this.cloudflareXsInput =
|
||
document.getElementById("cloudflareXs") || null;
|
||
this.cloudflareEnableCommentsInput =
|
||
document.getElementById("cloudflareEnableComments") || null;
|
||
this.cloudflareAdvancedToggle =
|
||
document.getElementById("cloudflareAdvancedToggle") || null;
|
||
this.cloudflareAdvancedToggleLabel =
|
||
document.getElementById("cloudflareAdvancedToggleLabel") || null;
|
||
this.cloudflareAdvancedToggleIcon =
|
||
document.getElementById("cloudflareAdvancedToggleIcon") || null;
|
||
this.cloudflareAdvancedFields =
|
||
document.getElementById("cloudflareAdvancedFields") || null;
|
||
this.r2AccountIdInput =
|
||
document.getElementById("r2AccountId") || null;
|
||
this.r2AccessKeyIdInput =
|
||
document.getElementById("r2AccessKeyId") || null;
|
||
this.r2SecretAccessKeyInput =
|
||
document.getElementById("r2SecretAccessKey") || null;
|
||
this.r2ApiTokenInput =
|
||
document.getElementById("r2ApiToken") || null;
|
||
this.r2ZoneIdInput = document.getElementById("r2ZoneId") || null;
|
||
this.r2BaseDomainInput =
|
||
document.getElementById("r2BaseDomain") || null;
|
||
|
||
// Optional: if close button found, wire up
|
||
if (this.closeUploadModalBtn) {
|
||
this.closeUploadModalBtn.addEventListener("click", () => {
|
||
if (this.uploadModal) {
|
||
this.uploadModal.classList.add("hidden");
|
||
}
|
||
});
|
||
}
|
||
// If the form is found, wire up
|
||
if (this.uploadForm) {
|
||
this.uploadForm.addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
this.handleUploadSubmit();
|
||
});
|
||
}
|
||
|
||
if (this.cloudflareSettingsForm) {
|
||
this.cloudflareSettingsForm.addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
this.handleCloudflareSettingsSubmit();
|
||
});
|
||
}
|
||
|
||
if (this.cloudflareClearSettingsButton) {
|
||
this.cloudflareClearSettingsButton.addEventListener("click", () => {
|
||
this.handleCloudflareClearSettings();
|
||
});
|
||
}
|
||
|
||
if (this.cloudflareUploadForm) {
|
||
this.cloudflareUploadForm.addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
this.handleCloudflareUploadSubmit();
|
||
});
|
||
}
|
||
|
||
if (this.uploadModeToggleButtons.length > 0) {
|
||
this.uploadModeToggleButtons.forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const mode = btn.dataset.uploadMode || "custom";
|
||
this.setUploadMode(mode);
|
||
});
|
||
});
|
||
}
|
||
|
||
if (this.cloudflareAdvancedToggle) {
|
||
this.cloudflareAdvancedToggle.addEventListener("click", () => {
|
||
this.setCloudflareAdvancedVisibility(
|
||
!this.cloudflareAdvancedVisible
|
||
);
|
||
});
|
||
}
|
||
|
||
this.setCloudflareAdvancedVisibility(this.cloudflareAdvancedVisible);
|
||
|
||
await this.loadCloudflareSettingsFromStorage();
|
||
await this.updateCloudflareBucketPreview();
|
||
|
||
this.setUploadMode(this.activeUploadMode);
|
||
|
||
console.log("Upload modal initialization successful");
|
||
return true;
|
||
} catch (error) {
|
||
console.error("initUploadModal failed:", error);
|
||
this.showError(`Failed to initialize upload modal: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async initEditVideoModal() {
|
||
try {
|
||
let modal = document.getElementById("editVideoModal");
|
||
if (!modal) {
|
||
const resp = await fetch("components/edit-video-modal.html");
|
||
if (!resp.ok) {
|
||
throw new Error(`HTTP error! status: ${resp.status}`);
|
||
}
|
||
const html = await resp.text();
|
||
const modalContainer = document.getElementById("modalContainer");
|
||
if (!modalContainer) {
|
||
throw new Error("Modal container element not found!");
|
||
}
|
||
|
||
const wrapper = document.createElement("div");
|
||
wrapper.innerHTML = html;
|
||
removeTrackingScripts(wrapper);
|
||
modalContainer.appendChild(wrapper);
|
||
|
||
modal = wrapper.querySelector("#editVideoModal");
|
||
}
|
||
|
||
if (!modal) {
|
||
throw new Error("Edit video modal markup missing after load.");
|
||
}
|
||
|
||
this.editVideoModal = modal;
|
||
this.editVideoOverlay =
|
||
modal.querySelector("#editVideoModalOverlay") || null;
|
||
this.editVideoForm = modal.querySelector("#editVideoForm") || null;
|
||
this.closeEditVideoModalBtn =
|
||
modal.querySelector("#closeEditVideoModal") || null;
|
||
this.cancelEditVideoBtn =
|
||
modal.querySelector("#cancelEditVideo") || null;
|
||
this.editVideoSubmitBtn =
|
||
modal.querySelector("#submitEditVideo") || null;
|
||
this.editVideoFieldButtons = Array.from(
|
||
modal.querySelectorAll("[data-edit-target]")
|
||
);
|
||
|
||
if (this.editVideoForm) {
|
||
this.editVideoForm.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
this.handleEditVideoSubmit();
|
||
});
|
||
}
|
||
|
||
if (this.closeEditVideoModalBtn) {
|
||
this.closeEditVideoModalBtn.addEventListener("click", () => {
|
||
this.hideEditVideoModal();
|
||
});
|
||
}
|
||
|
||
if (this.cancelEditVideoBtn) {
|
||
this.cancelEditVideoBtn.addEventListener("click", () => {
|
||
this.hideEditVideoModal();
|
||
});
|
||
}
|
||
|
||
if (this.editVideoOverlay) {
|
||
this.editVideoOverlay.addEventListener("click", () => {
|
||
this.hideEditVideoModal();
|
||
});
|
||
}
|
||
|
||
if (Array.isArray(this.editVideoFieldButtons)) {
|
||
this.editVideoFieldButtons.forEach((btn) => {
|
||
btn.addEventListener("click", (event) => {
|
||
this.handleEditFieldToggle(event);
|
||
});
|
||
});
|
||
}
|
||
|
||
this.resetEditVideoForm();
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error("initEditVideoModal failed:", error);
|
||
this.showError(`Failed to initialize edit modal: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
resetEditVideoForm() {
|
||
if (!this.editVideoModal) {
|
||
this.activeEditVideo = null;
|
||
return;
|
||
}
|
||
|
||
const fieldIds = [
|
||
"editVideoTitle",
|
||
"editVideoUrl",
|
||
"editVideoMagnet",
|
||
"editVideoWs",
|
||
"editVideoXs",
|
||
"editVideoThumbnail",
|
||
"editVideoDescription",
|
||
"editEnableComments",
|
||
];
|
||
|
||
fieldIds.forEach((id) => {
|
||
const input = this.editVideoModal.querySelector(`#${id}`);
|
||
if (input) {
|
||
if (input.type === "checkbox") {
|
||
input.checked = true;
|
||
input.disabled = false;
|
||
} else {
|
||
input.value = "";
|
||
input.readOnly = false;
|
||
input.classList.remove("locked-input");
|
||
}
|
||
delete input.dataset.originalValue;
|
||
}
|
||
const button = this.editVideoModal.querySelector(
|
||
`[data-edit-target="${id}"]`
|
||
);
|
||
if (button) {
|
||
button.classList.add("hidden");
|
||
button.dataset.mode = "locked";
|
||
button.textContent = "Edit field";
|
||
}
|
||
});
|
||
|
||
this.activeEditVideo = null;
|
||
}
|
||
|
||
showEditVideoModal() {
|
||
if (this.editVideoModal) {
|
||
this.editVideoModal.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
hideEditVideoModal() {
|
||
if (this.editVideoModal) {
|
||
this.editVideoModal.classList.add("hidden");
|
||
}
|
||
this.resetEditVideoForm();
|
||
}
|
||
|
||
async initRevertVideoModal() {
|
||
try {
|
||
let modal = document.getElementById("revertVideoModal");
|
||
if (!modal) {
|
||
const response = await fetch("components/revert-video-modal.html");
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
const html = await response.text();
|
||
const modalContainer = document.getElementById("modalContainer");
|
||
if (!modalContainer) {
|
||
throw new Error("Modal container element not found!");
|
||
}
|
||
|
||
const wrapper = document.createElement("div");
|
||
wrapper.innerHTML = html;
|
||
removeTrackingScripts(wrapper);
|
||
modalContainer.appendChild(wrapper);
|
||
|
||
modal = wrapper.querySelector("#revertVideoModal");
|
||
}
|
||
|
||
if (!modal) {
|
||
throw new Error("Revert video modal markup missing after load.");
|
||
}
|
||
|
||
this.revertVideoModal = modal;
|
||
this.revertVideoOverlay =
|
||
modal.querySelector("#revertVideoModalOverlay") || null;
|
||
this.revertVersionsList =
|
||
modal.querySelector("#revertVersionsList") || null;
|
||
this.revertVersionDetails =
|
||
modal.querySelector("#revertVersionDetails") || null;
|
||
this.revertVersionPlaceholder =
|
||
modal.querySelector("#revertVersionPlaceholder") || null;
|
||
if (this.revertVersionDetails && !this.revertVersionDetailsDefaultHTML) {
|
||
this.revertVersionDetailsDefaultHTML = this.revertVersionDetails.innerHTML;
|
||
}
|
||
this.revertHistoryCount =
|
||
modal.querySelector("#revertHistoryCount") || null;
|
||
this.revertSelectionStatus =
|
||
modal.querySelector("#revertSelectionStatus") || null;
|
||
this.revertModalTitle =
|
||
modal.querySelector("#revertModalTitle") || null;
|
||
this.revertModalSubtitle =
|
||
modal.querySelector("#revertModalSubtitle") || null;
|
||
this.closeRevertVideoModalBtn =
|
||
modal.querySelector("#closeRevertVideoModal") || null;
|
||
this.cancelRevertVideoBtn =
|
||
modal.querySelector("#cancelRevertVideo") || null;
|
||
this.confirmRevertVideoBtn =
|
||
modal.querySelector("#confirmRevertVideo") || null;
|
||
|
||
if (
|
||
this.revertVersionsList &&
|
||
!this.revertVersionsList.dataset.listenerAttached
|
||
) {
|
||
this.revertVersionsList.addEventListener("click", (event) => {
|
||
this.handleRevertVersionListClick(event);
|
||
});
|
||
this.revertVersionsList.dataset.listenerAttached = "true";
|
||
}
|
||
|
||
if (this.closeRevertVideoModalBtn && !this.closeRevertVideoModalBtn.dataset.listenerAttached) {
|
||
this.closeRevertVideoModalBtn.addEventListener("click", () => {
|
||
if (!this.revertModalBusy) {
|
||
this.hideRevertVideoModal();
|
||
}
|
||
});
|
||
this.closeRevertVideoModalBtn.dataset.listenerAttached = "true";
|
||
}
|
||
|
||
if (this.cancelRevertVideoBtn && !this.cancelRevertVideoBtn.dataset.listenerAttached) {
|
||
this.cancelRevertVideoBtn.addEventListener("click", () => {
|
||
if (!this.revertModalBusy) {
|
||
this.hideRevertVideoModal();
|
||
}
|
||
});
|
||
this.cancelRevertVideoBtn.dataset.listenerAttached = "true";
|
||
}
|
||
|
||
if (this.revertVideoOverlay && !this.revertVideoOverlay.dataset.listenerAttached) {
|
||
this.revertVideoOverlay.addEventListener("click", () => {
|
||
if (!this.revertModalBusy) {
|
||
this.hideRevertVideoModal();
|
||
}
|
||
});
|
||
this.revertVideoOverlay.dataset.listenerAttached = "true";
|
||
}
|
||
|
||
if (
|
||
this.confirmRevertVideoBtn &&
|
||
!this.confirmRevertVideoBtn.dataset.listenerAttached
|
||
) {
|
||
this.confirmRevertVideoBtn.addEventListener("click", () => {
|
||
this.handleConfirmRevertSelection();
|
||
});
|
||
this.confirmRevertVideoBtn.dataset.listenerAttached = "true";
|
||
}
|
||
|
||
this.resetRevertVideoModal();
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error("initRevertVideoModal failed:", error);
|
||
this.showError(`Failed to initialize revert modal: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
resetRevertVideoModal() {
|
||
this.activeRevertVideo = null;
|
||
this.revertHistory = [];
|
||
this.selectedRevertTarget = null;
|
||
this.revertModalBusy = false;
|
||
this.pendingRevertEntries = [];
|
||
|
||
if (this.revertHistoryCount) {
|
||
this.revertHistoryCount.textContent = "";
|
||
}
|
||
|
||
if (this.revertSelectionStatus) {
|
||
this.revertSelectionStatus.textContent =
|
||
"Select an older revision to inspect its metadata before reverting.";
|
||
}
|
||
|
||
if (this.revertModalTitle) {
|
||
this.revertModalTitle.textContent = "Revert Video Note";
|
||
}
|
||
|
||
if (this.revertModalSubtitle) {
|
||
this.revertModalSubtitle.textContent =
|
||
"Review previous versions before restoring an older state.";
|
||
}
|
||
|
||
if (this.revertVersionsList) {
|
||
this.revertVersionsList.innerHTML = "";
|
||
}
|
||
|
||
if (this.revertVersionDetails && this.revertVersionDetailsDefaultHTML) {
|
||
this.revertVersionDetails.innerHTML = this.revertVersionDetailsDefaultHTML;
|
||
this.revertVersionPlaceholder =
|
||
this.revertVersionDetails.querySelector("#revertVersionPlaceholder");
|
||
}
|
||
|
||
if (this.confirmRevertVideoBtn) {
|
||
this.confirmRevertVideoBtn.disabled = true;
|
||
this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel;
|
||
this.confirmRevertVideoBtn.classList.remove("cursor-wait");
|
||
}
|
||
|
||
if (this.cancelRevertVideoBtn) {
|
||
this.cancelRevertVideoBtn.disabled = false;
|
||
this.cancelRevertVideoBtn.classList.remove("opacity-60", "cursor-not-allowed");
|
||
}
|
||
|
||
if (this.closeRevertVideoModalBtn) {
|
||
this.closeRevertVideoModalBtn.disabled = false;
|
||
this.closeRevertVideoModalBtn.classList.remove("opacity-60", "cursor-not-allowed");
|
||
}
|
||
}
|
||
|
||
showRevertVideoModal() {
|
||
if (this.revertVideoModal) {
|
||
this.revertVideoModal.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
hideRevertVideoModal() {
|
||
if (this.revertVideoModal) {
|
||
this.revertVideoModal.classList.add("hidden");
|
||
}
|
||
this.resetRevertVideoModal();
|
||
}
|
||
|
||
populateRevertVideoModal(video, history = []) {
|
||
if (!this.revertVideoModal) {
|
||
return;
|
||
}
|
||
|
||
this.resetRevertVideoModal();
|
||
|
||
if (!video || typeof video !== "object") {
|
||
if (this.revertSelectionStatus) {
|
||
this.revertSelectionStatus.textContent =
|
||
"Unable to load revision history for this note.";
|
||
}
|
||
return;
|
||
}
|
||
|
||
this.activeRevertVideo = video;
|
||
|
||
const merged = Array.isArray(history) ? history.slice() : [];
|
||
if (video.id && !merged.some((entry) => entry && entry.id === video.id)) {
|
||
merged.push(video);
|
||
}
|
||
|
||
const deduped = new Map();
|
||
for (const entry of merged) {
|
||
if (!entry || typeof entry !== "object" || !entry.id) {
|
||
continue;
|
||
}
|
||
deduped.set(entry.id, entry);
|
||
}
|
||
|
||
this.revertHistory = Array.from(deduped.values()).sort(
|
||
(a, b) => b.created_at - a.created_at
|
||
);
|
||
|
||
this.selectedRevertTarget = null;
|
||
if (this.revertHistory.length > 1 && video.created_at) {
|
||
// Auto-select the newest non-deleted revision that predates the active
|
||
// event so the confirmation button immediately conveys its effect.
|
||
const firstOlder = this.revertHistory.find(
|
||
(entry) =>
|
||
entry &&
|
||
entry.id !== video.id &&
|
||
entry.deleted !== true &&
|
||
typeof entry.created_at === "number" &&
|
||
entry.created_at < video.created_at
|
||
);
|
||
if (firstOlder) {
|
||
this.selectedRevertTarget = firstOlder;
|
||
}
|
||
}
|
||
|
||
if (this.revertHistoryCount) {
|
||
this.revertHistoryCount.textContent = `${this.revertHistory.length}`;
|
||
}
|
||
|
||
if (this.revertModalTitle) {
|
||
this.revertModalTitle.textContent = video.title
|
||
? `Revert “${video.title}”`
|
||
: "Revert Video Note";
|
||
}
|
||
|
||
if (this.revertModalSubtitle) {
|
||
const subtitleParts = [];
|
||
const dTagValue = this.extractDTagValue(video.tags);
|
||
if (dTagValue) {
|
||
subtitleParts.push(`d=${dTagValue}`);
|
||
}
|
||
if (video.videoRootId) {
|
||
subtitleParts.push(`root=${truncateMiddle(video.videoRootId, 40)}`);
|
||
}
|
||
this.revertModalSubtitle.textContent = subtitleParts.length
|
||
? `History grouped by ${subtitleParts.join(" • ")}`
|
||
: "Review previous versions before restoring an older state.";
|
||
}
|
||
|
||
this.renderRevertVersionsList();
|
||
|
||
if (this.selectedRevertTarget) {
|
||
this.renderRevertVersionDetails(this.selectedRevertTarget);
|
||
}
|
||
|
||
if (this.revertHistory.length <= 1) {
|
||
if (this.revertSelectionStatus) {
|
||
this.revertSelectionStatus.textContent =
|
||
"No earlier revisions are available for this note.";
|
||
}
|
||
this.updateRevertConfirmationState();
|
||
return;
|
||
}
|
||
|
||
this.updateRevertConfirmationState();
|
||
}
|
||
|
||
renderRevertVersionsList() {
|
||
if (!this.revertVersionsList) {
|
||
return;
|
||
}
|
||
|
||
const history = Array.isArray(this.revertHistory)
|
||
? this.revertHistory
|
||
: [];
|
||
const selectedId = this.selectedRevertTarget?.id || "";
|
||
const currentId = this.activeRevertVideo?.id || "";
|
||
|
||
this.revertVersionsList.innerHTML = "";
|
||
|
||
if (!history.length) {
|
||
this.revertVersionsList.innerHTML =
|
||
'<p class="text-xs text-gray-500">No revisions found.</p>';
|
||
return;
|
||
}
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
|
||
history.forEach((entry) => {
|
||
if (!entry || !entry.id) {
|
||
return;
|
||
}
|
||
|
||
const isCurrent = entry.id === currentId;
|
||
const isSelected = entry.id === selectedId;
|
||
const isDeleted = entry.deleted === true;
|
||
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.dataset.revertVersionId = entry.id;
|
||
|
||
const classes = [
|
||
"w-full",
|
||
"text-left",
|
||
"rounded-md",
|
||
"border",
|
||
"px-3",
|
||
"py-3",
|
||
"text-sm",
|
||
"transition",
|
||
"duration-150",
|
||
"focus:outline-none",
|
||
"focus:ring-2",
|
||
"focus:ring-offset-2",
|
||
"focus:ring-offset-gray-900",
|
||
];
|
||
|
||
if (isSelected) {
|
||
classes.push(
|
||
"border-blue-500",
|
||
"bg-blue-500/20",
|
||
"text-blue-100",
|
||
"focus:ring-blue-500"
|
||
);
|
||
} else if (isCurrent) {
|
||
classes.push(
|
||
"border-green-500/60",
|
||
"bg-green-500/10",
|
||
"text-green-100",
|
||
"focus:ring-green-500/80"
|
||
);
|
||
} else if (isDeleted) {
|
||
classes.push(
|
||
"border-red-800/70",
|
||
"bg-red-900/30",
|
||
"text-red-200/90",
|
||
"hover:bg-red-900/40"
|
||
);
|
||
} else {
|
||
classes.push(
|
||
"border-gray-800",
|
||
"bg-gray-800/60",
|
||
"hover:bg-gray-700/70",
|
||
"text-gray-200"
|
||
);
|
||
}
|
||
|
||
button.className = classes.join(" ");
|
||
button.setAttribute("aria-pressed", isSelected ? "true" : "false");
|
||
if (isCurrent) {
|
||
button.setAttribute("aria-current", "true");
|
||
}
|
||
|
||
const relative = this.formatTimeAgo(entry.created_at);
|
||
const absolute = this.formatAbsoluteTimestamp(entry.created_at);
|
||
const versionLabel =
|
||
entry.version !== undefined ? `v${entry.version}` : "v?";
|
||
|
||
const metaParts = [];
|
||
if (isCurrent) {
|
||
metaParts.push("Current version");
|
||
}
|
||
if (entry.deleted) {
|
||
metaParts.push("Marked deleted");
|
||
}
|
||
if (entry.isPrivate) {
|
||
metaParts.push("Private");
|
||
}
|
||
const meta = metaParts.join(" • ");
|
||
|
||
const metaClass = entry.deleted
|
||
? "text-red-200/80"
|
||
: "text-gray-400";
|
||
|
||
button.innerHTML = `
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div class="space-y-1">
|
||
<p class="font-semibold">${this.escapeHTML(
|
||
entry.title || "Untitled"
|
||
)}</p>
|
||
<p class="text-xs text-gray-300">${this.escapeHTML(
|
||
relative
|
||
)} • ${this.escapeHTML(absolute)}</p>
|
||
${
|
||
meta
|
||
? `<p class="text-xs ${metaClass}">${this.escapeHTML(meta)}</p>`
|
||
: ""
|
||
}
|
||
</div>
|
||
<div class="text-xs uppercase tracking-wide text-gray-400">
|
||
${this.escapeHTML(versionLabel)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
fragment.appendChild(button);
|
||
});
|
||
|
||
this.revertVersionsList.appendChild(fragment);
|
||
}
|
||
|
||
handleRevertVersionListClick(event) {
|
||
const button = event?.target?.closest?.("[data-revert-version-id]");
|
||
if (!button || !button.dataset) {
|
||
return;
|
||
}
|
||
|
||
const versionId = button.dataset.revertVersionId;
|
||
if (!versionId) {
|
||
return;
|
||
}
|
||
|
||
const match = (this.revertHistory || []).find(
|
||
(entry) => entry && entry.id === versionId
|
||
);
|
||
if (!match) {
|
||
return;
|
||
}
|
||
|
||
this.selectedRevertTarget = match;
|
||
this.renderRevertVersionsList();
|
||
this.renderRevertVersionDetails(match);
|
||
this.updateRevertConfirmationState();
|
||
}
|
||
|
||
renderRevertVersionDetails(version) {
|
||
if (!this.revertVersionDetails) {
|
||
return;
|
||
}
|
||
|
||
if (!version) {
|
||
if (this.revertVersionDetailsDefaultHTML) {
|
||
this.revertVersionDetails.innerHTML =
|
||
this.revertVersionDetailsDefaultHTML;
|
||
this.revertVersionPlaceholder =
|
||
this.revertVersionDetails.querySelector("#revertVersionPlaceholder");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const absolute = this.formatAbsoluteTimestamp(version.created_at);
|
||
const relative = this.formatTimeAgo(version.created_at);
|
||
const description =
|
||
typeof version.description === "string" ? version.description : "";
|
||
const thumbnail =
|
||
typeof version.thumbnail === "string" ? version.thumbnail.trim() : "";
|
||
const url = typeof version.url === "string" ? version.url.trim() : "";
|
||
const magnet =
|
||
typeof version.magnet === "string" ? version.magnet.trim() : "";
|
||
const rawMagnet =
|
||
typeof version.rawMagnet === "string" ? version.rawMagnet.trim() : "";
|
||
const displayMagnet = magnet || rawMagnet;
|
||
const isPrivate = version.isPrivate === true;
|
||
const dTagValue = this.extractDTagValue(version.tags);
|
||
|
||
const fallbackThumbnail = this.escapeHTML(FALLBACK_THUMBNAIL_SRC);
|
||
const thumbnailSrc = thumbnail
|
||
? this.escapeHTML(thumbnail)
|
||
: fallbackThumbnail;
|
||
const thumbnailAlt = thumbnail
|
||
? "Revision thumbnail"
|
||
: "Fallback thumbnail";
|
||
|
||
let urlHtml = '<span class="text-gray-500">None</span>';
|
||
if (url) {
|
||
const safeUrl = this.escapeHTML(url);
|
||
const displayUrl = this.escapeHTML(truncateMiddle(url, 72));
|
||
urlHtml = `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer" class="text-blue-400 hover:text-blue-300 break-all">${displayUrl}</a>`;
|
||
}
|
||
|
||
let magnetHtml = '<span class="text-gray-500">None</span>';
|
||
if (displayMagnet) {
|
||
const label = this.escapeHTML(truncateMiddle(displayMagnet, 72));
|
||
const caption = isPrivate
|
||
? '<span class="block text-xs text-purple-200/90 mt-1">Encrypted magnet (only decrypted locally for the owner).</span>'
|
||
: "";
|
||
magnetHtml = `<div class="break-all">${label}${caption}</div>`;
|
||
}
|
||
|
||
const chips = [];
|
||
if (version.deleted) {
|
||
chips.push(
|
||
'<span class="inline-flex items-center rounded-full border border-red-700/70 bg-red-900/40 px-2 py-0.5 text-xs text-red-200/90">Marked deleted</span>'
|
||
);
|
||
}
|
||
if (isPrivate) {
|
||
chips.push(
|
||
'<span class="inline-flex items-center rounded-full border border-purple-600/60 bg-purple-900/40 px-2 py-0.5 text-xs text-purple-200/90">Private</span>'
|
||
);
|
||
}
|
||
if (version.version !== undefined) {
|
||
chips.push(
|
||
`<span class="inline-flex items-center rounded-full border border-gray-700 bg-gray-800/80 px-2 py-0.5 text-xs text-gray-200">Schema v${this.escapeHTML(
|
||
String(version.version)
|
||
)}</span>`
|
||
);
|
||
}
|
||
|
||
const descriptionHtml = description
|
||
? `<p class="whitespace-pre-wrap text-gray-200">${this.escapeHTML(
|
||
description
|
||
)}</p>`
|
||
: '<p class="text-gray-500">No description provided.</p>';
|
||
|
||
const rootId =
|
||
typeof version.videoRootId === "string" ? version.videoRootId : "";
|
||
const rootDisplay = rootId
|
||
? this.escapeHTML(truncateMiddle(rootId, 64))
|
||
: "";
|
||
const eventDisplay = version.id
|
||
? this.escapeHTML(truncateMiddle(version.id, 64))
|
||
: "";
|
||
|
||
this.revertVersionDetails.innerHTML = `
|
||
<div class="space-y-4">
|
||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start">
|
||
<div class="overflow-hidden rounded-md border border-gray-800 bg-black/40 w-full max-w-sm">
|
||
<img
|
||
src="${thumbnailSrc}"
|
||
alt="${this.escapeHTML(thumbnailAlt)}"
|
||
class="w-full h-auto object-cover"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
<div class="flex-1 space-y-3">
|
||
<div class="space-y-1">
|
||
<h3 class="text-lg font-semibold text-white">${this.escapeHTML(
|
||
version.title || "Untitled"
|
||
)}</h3>
|
||
<p class="text-xs text-gray-400">${this.escapeHTML(
|
||
absolute
|
||
)} (${this.escapeHTML(relative)})</p>
|
||
</div>
|
||
${
|
||
chips.length
|
||
? `<div class="flex flex-wrap gap-2">${chips.join("")}</div>`
|
||
: ""
|
||
}
|
||
<div class="space-y-2 text-sm text-gray-200">
|
||
<div>
|
||
<span class="font-medium text-gray-300">Hosted URL:</span>
|
||
<div class="mt-1">${urlHtml}</div>
|
||
</div>
|
||
<div>
|
||
<span class="font-medium text-gray-300">Magnet:</span>
|
||
<div class="mt-1">${magnetHtml}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<h4 class="text-sm font-semibold text-gray-200">Description</h4>
|
||
${descriptionHtml}
|
||
</div>
|
||
|
||
<dl class="grid gap-3 sm:grid-cols-2 text-xs text-gray-300">
|
||
<div>
|
||
<dt class="font-semibold text-gray-200">Mode</dt>
|
||
<dd class="mt-1">${this.escapeHTML(version.mode || "live")}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="font-semibold text-gray-200">d tag</dt>
|
||
<dd class="mt-1">
|
||
${dTagValue
|
||
? `<code class="rounded bg-gray-800/80 px-1.5 py-0.5">${this.escapeHTML(
|
||
dTagValue
|
||
)}</code>`
|
||
: '<span class="text-gray-500">Not provided</span>'}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="font-semibold text-gray-200">videoRootId</dt>
|
||
<dd class="mt-1">
|
||
${rootDisplay
|
||
? `<code class="break-all rounded bg-gray-800/80 px-1.5 py-0.5" title="${this.escapeHTML(
|
||
rootId
|
||
)}">${rootDisplay}</code>`
|
||
: '<span class="text-gray-500">Not provided</span>'}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="font-semibold text-gray-200">Event ID</dt>
|
||
<dd class="mt-1">
|
||
${eventDisplay
|
||
? `<code class="break-all rounded bg-gray-800/80 px-1.5 py-0.5" title="${this.escapeHTML(
|
||
version.id || ""
|
||
)}">${eventDisplay}</code>`
|
||
: '<span class="text-gray-500">Unknown</span>'}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
updateRevertConfirmationState() {
|
||
if (!this.confirmRevertVideoBtn) {
|
||
return;
|
||
}
|
||
|
||
if (!this.selectedRevertTarget || !this.activeRevertVideo) {
|
||
this.pendingRevertEntries = [];
|
||
this.confirmRevertVideoBtn.disabled = true;
|
||
if (!this.revertModalBusy) {
|
||
this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel;
|
||
}
|
||
if (this.revertSelectionStatus) {
|
||
if ((this.revertHistory || []).length > 1) {
|
||
this.revertSelectionStatus.textContent =
|
||
"Select an older revision to enable reverting.";
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const target = this.selectedRevertTarget;
|
||
const activePubkey =
|
||
typeof this.activeRevertVideo.pubkey === "string"
|
||
? this.activeRevertVideo.pubkey.toLowerCase()
|
||
: "";
|
||
|
||
const revertCandidates = (this.revertHistory || []).filter((entry) => {
|
||
if (!entry || entry.id === target.id) {
|
||
return false;
|
||
}
|
||
if (entry.deleted) {
|
||
return false;
|
||
}
|
||
if (typeof entry.created_at !== "number") {
|
||
return false;
|
||
}
|
||
if (entry.created_at <= target.created_at) {
|
||
return false;
|
||
}
|
||
if (!entry.pubkey) {
|
||
return false;
|
||
}
|
||
const entryPubkey =
|
||
typeof entry.pubkey === "string" ? entry.pubkey.toLowerCase() : "";
|
||
if (activePubkey && entryPubkey !== activePubkey) {
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
this.pendingRevertEntries = revertCandidates;
|
||
|
||
const disable =
|
||
this.revertModalBusy ||
|
||
target.deleted === true ||
|
||
revertCandidates.length === 0;
|
||
|
||
this.confirmRevertVideoBtn.disabled = disable;
|
||
if (!this.revertModalBusy) {
|
||
this.confirmRevertVideoBtn.textContent = this.revertConfirmDefaultLabel;
|
||
}
|
||
|
||
if (this.revertSelectionStatus) {
|
||
if (target.deleted) {
|
||
this.revertSelectionStatus.textContent =
|
||
"This revision was previously marked as deleted and cannot become active.";
|
||
} else if (revertCandidates.length === 0) {
|
||
this.revertSelectionStatus.textContent =
|
||
"The selected revision is already the latest active version.";
|
||
} else {
|
||
const suffix = revertCandidates.length === 1 ? "revision" : "revisions";
|
||
this.revertSelectionStatus.textContent = `Reverting will mark ${revertCandidates.length} newer ${suffix} as reverted.`;
|
||
}
|
||
}
|
||
}
|
||
|
||
setRevertModalBusy(isBusy, label) {
|
||
this.revertModalBusy = Boolean(isBusy);
|
||
|
||
if (this.confirmRevertVideoBtn) {
|
||
const disableConfirm =
|
||
this.revertModalBusy ||
|
||
!this.selectedRevertTarget ||
|
||
!this.pendingRevertEntries ||
|
||
this.pendingRevertEntries.length === 0 ||
|
||
(this.selectedRevertTarget && this.selectedRevertTarget.deleted === true);
|
||
this.confirmRevertVideoBtn.disabled = disableConfirm;
|
||
this.confirmRevertVideoBtn.textContent = this.revertModalBusy
|
||
? label || "Reverting…"
|
||
: this.revertConfirmDefaultLabel;
|
||
this.confirmRevertVideoBtn.classList.toggle(
|
||
"cursor-wait",
|
||
this.revertModalBusy
|
||
);
|
||
}
|
||
|
||
const toggleDisabledStyles = (button) => {
|
||
if (!button) {
|
||
return;
|
||
}
|
||
button.disabled = this.revertModalBusy;
|
||
button.classList.toggle("opacity-60", this.revertModalBusy);
|
||
button.classList.toggle("cursor-not-allowed", this.revertModalBusy);
|
||
};
|
||
|
||
toggleDisabledStyles(this.cancelRevertVideoBtn);
|
||
toggleDisabledStyles(this.closeRevertVideoModalBtn);
|
||
|
||
if (!this.revertModalBusy) {
|
||
this.updateRevertConfirmationState();
|
||
}
|
||
}
|
||
|
||
async handleConfirmRevertSelection() {
|
||
if (!this.selectedRevertTarget) {
|
||
return;
|
||
}
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to revert.");
|
||
return;
|
||
}
|
||
|
||
const entries = Array.isArray(this.pendingRevertEntries)
|
||
? this.pendingRevertEntries.slice()
|
||
: [];
|
||
if (!entries.length) {
|
||
this.updateRevertConfirmationState();
|
||
return;
|
||
}
|
||
|
||
this.setRevertModalBusy(true, "Reverting…");
|
||
|
||
try {
|
||
for (const entry of entries) {
|
||
await nostrClient.revertVideo(
|
||
{
|
||
id: entry.id,
|
||
pubkey: entry.pubkey,
|
||
tags: entry.tags,
|
||
},
|
||
this.pubkey
|
||
);
|
||
}
|
||
|
||
await this.loadVideos();
|
||
|
||
const timestampLabel = this.formatAbsoluteTimestamp(
|
||
this.selectedRevertTarget.created_at
|
||
);
|
||
this.showSuccess(`Reverted to revision from ${timestampLabel}.`);
|
||
this.hideRevertVideoModal();
|
||
this.forceRefreshAllProfiles();
|
||
} catch (err) {
|
||
console.error("Failed to revert video:", err);
|
||
this.showError("Failed to revert video. Please try again.");
|
||
} finally {
|
||
this.setRevertModalBusy(false);
|
||
this.pendingRevertEntries = [];
|
||
}
|
||
}
|
||
|
||
extractDTagValue(tags) {
|
||
if (!Array.isArray(tags)) {
|
||
return "";
|
||
}
|
||
for (const tag of tags) {
|
||
if (!Array.isArray(tag) || tag.length < 2) {
|
||
continue;
|
||
}
|
||
if (tag[0] === "d" && typeof tag[1] === "string") {
|
||
return tag[1];
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
deriveVideoPointerInfo(video) {
|
||
if (!video || typeof video !== "object") {
|
||
return null;
|
||
}
|
||
|
||
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
|
||
const normalizedPubkey =
|
||
typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||
const kind =
|
||
typeof video.kind === "number" && Number.isFinite(video.kind)
|
||
? video.kind
|
||
: VIDEO_EVENT_KIND;
|
||
|
||
if (dTagValue && normalizedPubkey) {
|
||
const pointer = ["a", `${kind}:${normalizedPubkey}:${dTagValue}`];
|
||
const key = pointerArrayToKey(pointer);
|
||
if (key) {
|
||
return { pointer, key };
|
||
}
|
||
}
|
||
|
||
const fallbackId =
|
||
typeof video.id === "string" && video.id.trim()
|
||
? video.id.trim()
|
||
: "";
|
||
if (fallbackId) {
|
||
const pointer = ["e", fallbackId];
|
||
const key = pointerArrayToKey(pointer);
|
||
if (key) {
|
||
return { pointer, key };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
formatViewCountLabel(total) {
|
||
const value = Number.isFinite(total) ? Number(total) : 0;
|
||
const label = value === 1 ? "view" : "views";
|
||
return `${formatViewCount(value)} ${label}`;
|
||
}
|
||
|
||
ensureViewCountSubscription(pointerInfo) {
|
||
if (!pointerInfo || !pointerInfo.key || !pointerInfo.pointer) {
|
||
return null;
|
||
}
|
||
|
||
const existing = this.viewCountSubscriptions.get(pointerInfo.key);
|
||
if (existing) {
|
||
return existing;
|
||
}
|
||
|
||
const entry = {
|
||
pointer: pointerInfo.pointer,
|
||
key: pointerInfo.key,
|
||
token: null,
|
||
elements: new Set(),
|
||
lastTotal: null,
|
||
lastStatus: "idle",
|
||
lastText: "– views",
|
||
};
|
||
|
||
try {
|
||
const token = subscribeToVideoViewCount(
|
||
pointerInfo.pointer,
|
||
({ total, status }) => {
|
||
let text;
|
||
if (Number.isFinite(total)) {
|
||
const numeric = Number(total);
|
||
entry.lastTotal = numeric;
|
||
text = this.formatViewCountLabel(numeric);
|
||
} else if (status === "hydrating") {
|
||
text = "Loading views…";
|
||
} else {
|
||
text = "– views";
|
||
}
|
||
|
||
entry.lastStatus = status;
|
||
entry.lastText = text;
|
||
|
||
for (const el of Array.from(entry.elements)) {
|
||
if (!el || !el.isConnected) {
|
||
entry.elements.delete(el);
|
||
continue;
|
||
}
|
||
el.textContent = text;
|
||
}
|
||
}
|
||
);
|
||
entry.token = token;
|
||
this.viewCountSubscriptions.set(pointerInfo.key, entry);
|
||
return entry;
|
||
} catch (error) {
|
||
console.warn("[viewCount] Failed to subscribe to view counter:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
registerVideoViewCountElement(cardEl, pointerInfo) {
|
||
if (!cardEl || !pointerInfo) {
|
||
return;
|
||
}
|
||
|
||
const viewCountEl = cardEl.querySelector("[data-view-count]");
|
||
if (!viewCountEl) {
|
||
return;
|
||
}
|
||
|
||
if (!viewCountEl.textContent || !viewCountEl.textContent.trim()) {
|
||
viewCountEl.textContent = "– views";
|
||
}
|
||
|
||
const entry = this.ensureViewCountSubscription(pointerInfo);
|
||
if (!entry) {
|
||
return;
|
||
}
|
||
|
||
entry.elements.add(viewCountEl);
|
||
if (typeof pointerInfo.key === "string") {
|
||
viewCountEl.dataset.viewPointer = pointerInfo.key;
|
||
}
|
||
viewCountEl.textContent = entry.lastText;
|
||
}
|
||
|
||
pruneDetachedViewCountElements() {
|
||
for (const entry of this.viewCountSubscriptions.values()) {
|
||
if (!entry || !(entry.elements instanceof Set)) {
|
||
continue;
|
||
}
|
||
for (const el of Array.from(entry.elements)) {
|
||
if (!el || !el.isConnected) {
|
||
entry.elements.delete(el);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
teardownAllViewCountSubscriptions() {
|
||
const keys = Array.from(this.viewCountSubscriptions.keys());
|
||
keys.forEach((key) => {
|
||
const entry = this.viewCountSubscriptions.get(key);
|
||
if (entry && entry.token && entry.pointer) {
|
||
try {
|
||
unsubscribeFromVideoViewCount(entry.pointer, entry.token);
|
||
} catch (error) {
|
||
console.warn(
|
||
`[viewCount] Failed to unsubscribe from pointer ${key}:`,
|
||
error
|
||
);
|
||
}
|
||
}
|
||
this.viewCountSubscriptions.delete(key);
|
||
});
|
||
}
|
||
|
||
teardownModalViewCountSubscription() {
|
||
if (typeof this.modalViewCountUnsub === "function") {
|
||
try {
|
||
this.modalViewCountUnsub();
|
||
} catch (error) {
|
||
console.warn("[viewCount] Failed to tear down modal subscription:", error);
|
||
}
|
||
}
|
||
this.modalViewCountUnsub = null;
|
||
if (this.videoViewCountEl) {
|
||
this.videoViewCountEl.textContent = "– views";
|
||
if (this.videoViewCountEl.dataset?.viewPointer) {
|
||
delete this.videoViewCountEl.dataset.viewPointer;
|
||
}
|
||
}
|
||
}
|
||
|
||
subscribeModalViewCount(pointer, pointerKey) {
|
||
if (!this.videoViewCountEl) {
|
||
return;
|
||
}
|
||
|
||
this.teardownModalViewCountSubscription();
|
||
|
||
if (!pointer || !pointerKey) {
|
||
return;
|
||
}
|
||
|
||
this.videoViewCountEl.textContent = "Loading views…";
|
||
try {
|
||
const token = subscribeToVideoViewCount(pointer, ({ total, status }) => {
|
||
if (!this.videoViewCountEl) {
|
||
return;
|
||
}
|
||
|
||
if (Number.isFinite(total)) {
|
||
const numeric = Number(total);
|
||
this.videoViewCountEl.textContent = this.formatViewCountLabel(numeric);
|
||
return;
|
||
}
|
||
|
||
if (status === "hydrating") {
|
||
this.videoViewCountEl.textContent = "Loading views…";
|
||
} else {
|
||
this.videoViewCountEl.textContent = "– views";
|
||
}
|
||
});
|
||
|
||
this.modalViewCountUnsub = () => {
|
||
try {
|
||
unsubscribeFromVideoViewCount(pointer, token);
|
||
} catch (error) {
|
||
console.warn(
|
||
"[viewCount] Failed to unsubscribe modal view counter:",
|
||
error
|
||
);
|
||
} finally {
|
||
this.modalViewCountUnsub = null;
|
||
}
|
||
};
|
||
this.videoViewCountEl.dataset.viewPointer = pointerKey;
|
||
} catch (error) {
|
||
console.warn("[viewCount] Failed to subscribe modal view counter:", error);
|
||
this.videoViewCountEl.textContent = "– views";
|
||
}
|
||
}
|
||
|
||
populateEditVideoForm(video) {
|
||
if (!video || !this.editVideoModal) {
|
||
return;
|
||
}
|
||
|
||
this.resetEditVideoForm();
|
||
|
||
const magnetSource = video.magnet || video.rawMagnet || "";
|
||
const magnetHints = extractMagnetHints(magnetSource);
|
||
const effectiveWs = video.ws || magnetHints.ws || "";
|
||
const effectiveXs = video.xs || magnetHints.xs || "";
|
||
const enableCommentsValue =
|
||
typeof video.enableComments === "boolean"
|
||
? video.enableComments
|
||
: true;
|
||
|
||
const editContext = {
|
||
...video,
|
||
ws: effectiveWs,
|
||
xs: effectiveXs,
|
||
enableComments: enableCommentsValue,
|
||
};
|
||
|
||
const fieldMap = {
|
||
editVideoTitle: editContext.title || "",
|
||
editVideoUrl: editContext.url || "",
|
||
editVideoMagnet: editContext.magnet || "",
|
||
editVideoWs: editContext.ws || "",
|
||
editVideoXs: editContext.xs || "",
|
||
editVideoThumbnail: editContext.thumbnail || "",
|
||
editVideoDescription: editContext.description || "",
|
||
editEnableComments: editContext.enableComments,
|
||
};
|
||
|
||
Object.entries(fieldMap).forEach(([id, rawValue]) => {
|
||
const input = this.editVideoModal.querySelector(`#${id}`);
|
||
const button = this.editVideoModal.querySelector(
|
||
`[data-edit-target="${id}"]`
|
||
);
|
||
if (!input) {
|
||
if (button) {
|
||
button.classList.add("hidden");
|
||
button.dataset.mode = "locked";
|
||
button.textContent = "Edit field";
|
||
}
|
||
return;
|
||
}
|
||
|
||
const isCheckbox = input.type === "checkbox";
|
||
if (isCheckbox) {
|
||
const hasValue = rawValue !== undefined;
|
||
const boolValue = rawValue === true;
|
||
input.checked = boolValue;
|
||
input.disabled = hasValue;
|
||
input.dataset.originalValue = boolValue ? "true" : "false";
|
||
if (button) {
|
||
if (hasValue) {
|
||
button.classList.remove("hidden");
|
||
button.dataset.mode = "locked";
|
||
button.textContent = "Edit field";
|
||
} else {
|
||
button.classList.add("hidden");
|
||
button.dataset.mode = "locked";
|
||
button.textContent = "Edit field";
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const value = typeof rawValue === "string" ? rawValue : "";
|
||
const hasValue = value.trim().length > 0;
|
||
|
||
input.value = value;
|
||
input.dataset.originalValue = value;
|
||
if (hasValue) {
|
||
input.readOnly = true;
|
||
input.classList.add("locked-input");
|
||
} else {
|
||
input.readOnly = false;
|
||
input.classList.remove("locked-input");
|
||
}
|
||
|
||
if (button) {
|
||
if (hasValue) {
|
||
button.classList.remove("hidden");
|
||
button.dataset.mode = "locked";
|
||
button.textContent = "Edit field";
|
||
} else {
|
||
button.classList.add("hidden");
|
||
button.dataset.mode = "locked";
|
||
button.textContent = "Edit field";
|
||
}
|
||
}
|
||
});
|
||
|
||
this.activeEditVideo = editContext;
|
||
}
|
||
|
||
handleEditFieldToggle(event) {
|
||
const button = event?.currentTarget;
|
||
if (!button || !this.editVideoModal) {
|
||
return;
|
||
}
|
||
|
||
const targetId = button.dataset?.editTarget;
|
||
if (!targetId) {
|
||
return;
|
||
}
|
||
|
||
const input = this.editVideoModal.querySelector(`#${targetId}`);
|
||
if (!input) {
|
||
return;
|
||
}
|
||
|
||
const mode = button.dataset.mode || "locked";
|
||
const isCheckbox = input.type === "checkbox";
|
||
|
||
if (mode === "locked") {
|
||
if (isCheckbox) {
|
||
input.disabled = false;
|
||
} else {
|
||
input.readOnly = false;
|
||
input.classList.remove("locked-input");
|
||
}
|
||
button.dataset.mode = "editing";
|
||
button.textContent = "Restore original";
|
||
if (!isCheckbox && typeof input.focus === "function") {
|
||
input.focus();
|
||
if (typeof input.setSelectionRange === "function") {
|
||
const length = input.value.length;
|
||
try {
|
||
input.setSelectionRange(length, length);
|
||
} catch (err) {
|
||
// ignore selection errors (e.g. for input types that do not support it)
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const originalValue = input.dataset?.originalValue || "";
|
||
|
||
if (isCheckbox) {
|
||
input.checked = originalValue === "true";
|
||
input.disabled = true;
|
||
button.dataset.mode = "locked";
|
||
button.textContent = "Edit field";
|
||
return;
|
||
}
|
||
|
||
input.value = originalValue;
|
||
|
||
if (originalValue) {
|
||
input.readOnly = true;
|
||
input.classList.add("locked-input");
|
||
button.dataset.mode = "locked";
|
||
button.textContent = "Edit field";
|
||
} else {
|
||
input.readOnly = false;
|
||
input.classList.remove("locked-input");
|
||
button.classList.add("hidden");
|
||
button.dataset.mode = "locked";
|
||
button.textContent = "Edit field";
|
||
}
|
||
}
|
||
|
||
setUploadMode(mode) {
|
||
const normalized = mode === "cloudflare" ? "cloudflare" : "custom";
|
||
this.activeUploadMode = normalized;
|
||
|
||
if (this.customUploadSection) {
|
||
if (normalized === "custom") {
|
||
this.customUploadSection.classList.remove("hidden");
|
||
} else {
|
||
this.customUploadSection.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
if (this.cloudflareUploadSection) {
|
||
if (normalized === "cloudflare") {
|
||
this.cloudflareUploadSection.classList.remove("hidden");
|
||
} else {
|
||
this.cloudflareUploadSection.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(this.uploadModeToggleButtons)) {
|
||
this.uploadModeToggleButtons.forEach((btn) => {
|
||
if (!btn || !btn.dataset) {
|
||
return;
|
||
}
|
||
|
||
const isActive = btn.dataset.uploadMode === normalized;
|
||
btn.classList.toggle("bg-blue-500", isActive);
|
||
btn.classList.toggle("text-white", isActive);
|
||
btn.classList.toggle("shadow", isActive);
|
||
btn.classList.toggle("text-gray-300", !isActive);
|
||
btn.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||
});
|
||
}
|
||
|
||
if (normalized === "cloudflare") {
|
||
this.updateCloudflareBucketPreview();
|
||
}
|
||
}
|
||
|
||
setCloudflareAdvancedVisibility(visible) {
|
||
const isVisible = Boolean(visible);
|
||
this.cloudflareAdvancedVisible = isVisible;
|
||
|
||
if (this.cloudflareAdvancedFields) {
|
||
if (isVisible) {
|
||
this.cloudflareAdvancedFields.classList.remove("hidden");
|
||
} else {
|
||
this.cloudflareAdvancedFields.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
if (this.cloudflareAdvancedToggle) {
|
||
this.cloudflareAdvancedToggle.setAttribute(
|
||
"aria-expanded",
|
||
isVisible ? "true" : "false"
|
||
);
|
||
}
|
||
|
||
if (this.cloudflareAdvancedToggleLabel) {
|
||
this.cloudflareAdvancedToggleLabel.textContent = isVisible
|
||
? "Hide advanced options"
|
||
: "Show advanced options";
|
||
}
|
||
|
||
if (this.cloudflareAdvancedToggleIcon) {
|
||
this.cloudflareAdvancedToggleIcon.classList.toggle("rotate-90", isVisible);
|
||
}
|
||
}
|
||
|
||
setCloudflareSettingsStatus(message = "", variant = "info") {
|
||
if (!this.cloudflareSettingsStatus) {
|
||
return;
|
||
}
|
||
|
||
const el = this.cloudflareSettingsStatus;
|
||
el.textContent = message || "";
|
||
el.classList.remove(
|
||
"text-green-400",
|
||
"text-red-400",
|
||
"text-yellow-400",
|
||
"text-gray-400"
|
||
);
|
||
if (!message) {
|
||
el.classList.add("text-gray-400");
|
||
return;
|
||
}
|
||
|
||
let cls = "text-gray-400";
|
||
if (variant === "success") {
|
||
cls = "text-green-400";
|
||
} else if (variant === "error") {
|
||
cls = "text-red-400";
|
||
} else if (variant === "warning") {
|
||
cls = "text-yellow-400";
|
||
}
|
||
el.classList.add(cls);
|
||
}
|
||
|
||
setCloudflareUploadStatus(message = "", variant = "info") {
|
||
if (!this.cloudflareUploadStatus) {
|
||
return;
|
||
}
|
||
|
||
const el = this.cloudflareUploadStatus;
|
||
el.textContent = message || "";
|
||
el.classList.remove(
|
||
"text-green-400",
|
||
"text-red-400",
|
||
"text-yellow-400",
|
||
"text-gray-400"
|
||
);
|
||
if (!message) {
|
||
el.classList.add("text-gray-400");
|
||
return;
|
||
}
|
||
|
||
let cls = "text-gray-400";
|
||
if (variant === "success") {
|
||
cls = "text-green-400";
|
||
} else if (variant === "error") {
|
||
cls = "text-red-400";
|
||
} else if (variant === "warning") {
|
||
cls = "text-yellow-400";
|
||
}
|
||
el.classList.add(cls);
|
||
}
|
||
|
||
setCloudflareUploading(isUploading) {
|
||
if (this.cloudflareUploadButton) {
|
||
this.cloudflareUploadButton.disabled = Boolean(isUploading);
|
||
this.cloudflareUploadButton.textContent = isUploading
|
||
? "Uploading…"
|
||
: "Upload to R2 & publish";
|
||
}
|
||
|
||
if (this.cloudflareFileInput) {
|
||
this.cloudflareFileInput.disabled = Boolean(isUploading);
|
||
}
|
||
|
||
if (this.cloudflareEnableCommentsInput) {
|
||
this.cloudflareEnableCommentsInput.disabled = Boolean(isUploading);
|
||
}
|
||
}
|
||
|
||
updateCloudflareProgress(fraction) {
|
||
if (!this.cloudflareProgressBar || !this.cloudflareProgressFill) {
|
||
return;
|
||
}
|
||
|
||
if (typeof fraction !== "number" || Number.isNaN(fraction)) {
|
||
this.cloudflareProgressBar.classList.add("hidden");
|
||
this.cloudflareProgressFill.style.width = "0%";
|
||
return;
|
||
}
|
||
|
||
const clamped = Math.max(0, Math.min(1, fraction));
|
||
this.cloudflareProgressBar.classList.remove("hidden");
|
||
this.cloudflareProgressFill.style.width = `${(clamped * 100).toFixed(1)}%`;
|
||
}
|
||
|
||
resetCloudflareUploadForm() {
|
||
if (this.cloudflareTitleInput) this.cloudflareTitleInput.value = "";
|
||
if (this.cloudflareDescriptionInput)
|
||
this.cloudflareDescriptionInput.value = "";
|
||
if (this.cloudflareThumbnailInput)
|
||
this.cloudflareThumbnailInput.value = "";
|
||
if (this.cloudflareMagnetInput) this.cloudflareMagnetInput.value = "";
|
||
if (this.cloudflareWsInput) this.cloudflareWsInput.value = "";
|
||
if (this.cloudflareXsInput) this.cloudflareXsInput.value = "";
|
||
if (this.cloudflareEnableCommentsInput)
|
||
this.cloudflareEnableCommentsInput.checked = true;
|
||
if (this.cloudflareFileInput) this.cloudflareFileInput.value = "";
|
||
this.updateCloudflareProgress(Number.NaN);
|
||
}
|
||
|
||
async loadCloudflareSettingsFromStorage() {
|
||
try {
|
||
const settings = await loadR2Settings();
|
||
this.cloudflareSettings = settings;
|
||
this.populateCloudflareSettingsInputs(settings);
|
||
} catch (err) {
|
||
console.error("Failed to load Cloudflare settings:", err);
|
||
this.setCloudflareSettingsStatus(
|
||
"Failed to load saved settings.",
|
||
"error"
|
||
);
|
||
this.cloudflareSettings = {
|
||
accountId: "",
|
||
accessKeyId: "",
|
||
secretAccessKey: "",
|
||
apiToken: "",
|
||
zoneId: "",
|
||
baseDomain: "",
|
||
buckets: {},
|
||
};
|
||
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
|
||
}
|
||
}
|
||
|
||
populateCloudflareSettingsInputs(settings) {
|
||
const data =
|
||
settings || {
|
||
accountId: "",
|
||
accessKeyId: "",
|
||
secretAccessKey: "",
|
||
apiToken: "",
|
||
zoneId: "",
|
||
baseDomain: "",
|
||
};
|
||
|
||
if (this.r2AccountIdInput) {
|
||
this.r2AccountIdInput.value = data.accountId || "";
|
||
}
|
||
if (this.r2AccessKeyIdInput) {
|
||
this.r2AccessKeyIdInput.value = data.accessKeyId || "";
|
||
}
|
||
if (this.r2SecretAccessKeyInput) {
|
||
this.r2SecretAccessKeyInput.value = data.secretAccessKey || "";
|
||
}
|
||
if (this.r2ApiTokenInput) {
|
||
this.r2ApiTokenInput.value = data.apiToken || "";
|
||
}
|
||
if (this.r2ZoneIdInput) {
|
||
this.r2ZoneIdInput.value = data.zoneId || "";
|
||
}
|
||
if (this.r2BaseDomainInput) {
|
||
this.r2BaseDomainInput.value = data.baseDomain || "";
|
||
}
|
||
|
||
const hasAdvancedValues = Boolean(
|
||
(data.apiToken && data.apiToken.length > 0) ||
|
||
(data.zoneId && data.zoneId.length > 0) ||
|
||
(data.baseDomain && data.baseDomain.length > 0)
|
||
);
|
||
if (hasAdvancedValues) {
|
||
this.setCloudflareAdvancedVisibility(true);
|
||
} else if (!this.cloudflareAdvancedVisible) {
|
||
this.setCloudflareAdvancedVisibility(false);
|
||
}
|
||
|
||
this.setCloudflareSettingsStatus("");
|
||
}
|
||
|
||
async handleCloudflareSettingsSubmit({ quiet = false } = {}) {
|
||
const accountId = (this.r2AccountIdInput?.value || "").trim();
|
||
const accessKeyId = (this.r2AccessKeyIdInput?.value || "").trim();
|
||
const secretAccessKey = (this.r2SecretAccessKeyInput?.value || "").trim();
|
||
const apiToken = (this.r2ApiTokenInput?.value || "").trim();
|
||
const zoneId = (this.r2ZoneIdInput?.value || "").trim();
|
||
const baseDomain = sanitizeBaseDomain(
|
||
this.r2BaseDomainInput?.value || ""
|
||
);
|
||
|
||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||
if (!quiet) {
|
||
this.setCloudflareSettingsStatus(
|
||
"Account ID, Access Key ID, and Secret are required.",
|
||
"error"
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
let buckets = { ...(this.cloudflareSettings?.buckets || {}) };
|
||
const previousAccount = this.cloudflareSettings?.accountId || "";
|
||
const previousBaseDomain = this.cloudflareSettings?.baseDomain || "";
|
||
const previousZoneId = this.cloudflareSettings?.zoneId || "";
|
||
if (
|
||
previousAccount !== accountId ||
|
||
previousBaseDomain !== baseDomain ||
|
||
previousZoneId !== zoneId
|
||
) {
|
||
buckets = {};
|
||
}
|
||
|
||
const updatedSettings = {
|
||
accountId,
|
||
accessKeyId,
|
||
secretAccessKey,
|
||
apiToken,
|
||
zoneId,
|
||
baseDomain,
|
||
buckets,
|
||
};
|
||
|
||
try {
|
||
this.cloudflareSettings = await saveR2Settings(updatedSettings);
|
||
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
|
||
if (!quiet) {
|
||
this.setCloudflareSettingsStatus("Settings saved locally.", "success");
|
||
}
|
||
await this.updateCloudflareBucketPreview();
|
||
return true;
|
||
} catch (err) {
|
||
console.error("Failed to save Cloudflare settings:", err);
|
||
if (!quiet) {
|
||
this.setCloudflareSettingsStatus(
|
||
"Failed to save settings. Check console for details.",
|
||
"error"
|
||
);
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async handleCloudflareClearSettings() {
|
||
try {
|
||
await clearR2Settings();
|
||
this.cloudflareSettings = await loadR2Settings();
|
||
this.populateCloudflareSettingsInputs(this.cloudflareSettings);
|
||
this.setCloudflareAdvancedVisibility(false);
|
||
this.setCloudflareSettingsStatus("Settings cleared.", "success");
|
||
await this.updateCloudflareBucketPreview();
|
||
} catch (err) {
|
||
console.error("Failed to clear Cloudflare settings:", err);
|
||
this.setCloudflareSettingsStatus(
|
||
"Failed to clear settings.",
|
||
"error"
|
||
);
|
||
}
|
||
}
|
||
|
||
getCorsOrigins() {
|
||
const origins = new Set();
|
||
if (typeof window !== "undefined" && window.location) {
|
||
const origin = window.location.origin;
|
||
if (origin && origin !== "null") {
|
||
origins.add(origin);
|
||
}
|
||
if (origin && origin.startsWith("http://localhost")) {
|
||
origins.add(origin.replace("http://", "https://"));
|
||
}
|
||
}
|
||
return Array.from(origins);
|
||
}
|
||
|
||
deriveSubdomainForNpub(npub) {
|
||
try {
|
||
return deriveShortSubdomain(npub);
|
||
} catch (err) {
|
||
console.warn("Failed to derive short subdomain, falling back:", err);
|
||
}
|
||
|
||
const base = String(npub || "user")
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9-]/g, "")
|
||
.replace(/^-+|[-]+$/g, "");
|
||
return base.slice(0, 32) || "user";
|
||
}
|
||
|
||
async ensureBucketConfigForNpub(npub) {
|
||
if (!npub || !this.cloudflareSettings) {
|
||
return null;
|
||
}
|
||
|
||
const accountId = (this.cloudflareSettings.accountId || "").trim();
|
||
const apiToken = (this.cloudflareSettings.apiToken || "").trim();
|
||
const zoneId = (this.cloudflareSettings.zoneId || "").trim();
|
||
const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim();
|
||
const secretAccessKey =
|
||
(this.cloudflareSettings.secretAccessKey || "").trim();
|
||
const corsOrigins = this.getCorsOrigins();
|
||
const baseDomain = this.cloudflareSettings.baseDomain || "";
|
||
|
||
if (!accountId) {
|
||
throw new Error("Cloudflare account ID is missing.");
|
||
}
|
||
|
||
let entry = this.cloudflareSettings.buckets?.[npub] || null;
|
||
|
||
if (entry && entry.publicBaseUrl) {
|
||
if (apiToken) {
|
||
try {
|
||
await ensureBucket({
|
||
accountId,
|
||
bucket: entry.bucket,
|
||
token: apiToken,
|
||
});
|
||
await putCors({
|
||
accountId,
|
||
bucket: entry.bucket,
|
||
token: apiToken,
|
||
origins: corsOrigins,
|
||
});
|
||
} catch (err) {
|
||
console.warn("Failed to refresh bucket configuration:", err);
|
||
}
|
||
} else if (
|
||
accessKeyId &&
|
||
secretAccessKey &&
|
||
corsOrigins.length > 0
|
||
) {
|
||
try {
|
||
const s3 = makeR2Client({
|
||
accountId,
|
||
accessKeyId,
|
||
secretAccessKey,
|
||
});
|
||
await ensureBucketCors({
|
||
s3,
|
||
bucket: entry.bucket,
|
||
origins: corsOrigins,
|
||
});
|
||
} catch (err) {
|
||
console.warn(
|
||
"Failed to refresh bucket CORS via access keys:",
|
||
err
|
||
);
|
||
}
|
||
}
|
||
return {
|
||
entry,
|
||
usedManagedFallback: entry.domainType !== "custom",
|
||
customDomainStatus: entry.domainType === "custom" ? "active" : "skipped",
|
||
};
|
||
}
|
||
|
||
if (!apiToken) {
|
||
const bucketName = entry?.bucket || sanitizeBucketName(npub);
|
||
const manualCustomDomain = baseDomain
|
||
? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}`
|
||
: "";
|
||
|
||
let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain;
|
||
if (!publicBaseUrl) {
|
||
publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`;
|
||
}
|
||
|
||
if (!publicBaseUrl) {
|
||
throw new Error(
|
||
"No public bucket domain configured. Add an API token or configure the domain manually."
|
||
);
|
||
}
|
||
|
||
const manualEntry = {
|
||
bucket: bucketName,
|
||
publicBaseUrl,
|
||
domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom",
|
||
lastUpdated: Date.now(),
|
||
};
|
||
|
||
if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
|
||
try {
|
||
const s3 = makeR2Client({
|
||
accountId,
|
||
accessKeyId,
|
||
secretAccessKey,
|
||
});
|
||
await ensureBucketCors({
|
||
s3,
|
||
bucket: bucketName,
|
||
origins: corsOrigins,
|
||
});
|
||
} catch (corsErr) {
|
||
console.warn(
|
||
"Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.",
|
||
corsErr
|
||
);
|
||
}
|
||
}
|
||
|
||
let savedEntry = entry;
|
||
if (
|
||
!entry ||
|
||
entry.bucket !== manualEntry.bucket ||
|
||
entry.publicBaseUrl !== manualEntry.publicBaseUrl ||
|
||
entry.domainType !== manualEntry.domainType
|
||
) {
|
||
const updatedSettings = await saveR2Settings(
|
||
mergeBucketEntry(this.cloudflareSettings, npub, manualEntry)
|
||
);
|
||
this.cloudflareSettings = updatedSettings;
|
||
savedEntry = updatedSettings.buckets?.[npub] || manualEntry;
|
||
}
|
||
|
||
return {
|
||
entry: savedEntry,
|
||
usedManagedFallback: manualEntry.domainType !== "custom",
|
||
customDomainStatus:
|
||
manualEntry.domainType === "custom" ? "manual" : "managed",
|
||
};
|
||
}
|
||
|
||
const bucketName = entry?.bucket || sanitizeBucketName(npub);
|
||
|
||
await ensureBucket({ accountId, bucket: bucketName, token: apiToken });
|
||
|
||
try {
|
||
await putCors({
|
||
accountId,
|
||
bucket: bucketName,
|
||
token: apiToken,
|
||
origins: corsOrigins,
|
||
});
|
||
} catch (err) {
|
||
console.warn("Failed to apply R2 CORS rules:", err);
|
||
}
|
||
|
||
let publicBaseUrl = entry?.publicBaseUrl || "";
|
||
let domainType = entry?.domainType || "managed";
|
||
let usedManagedFallback = false;
|
||
let customDomainStatus = "skipped";
|
||
|
||
if (baseDomain && zoneId) {
|
||
const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`;
|
||
try {
|
||
const custom = await attachCustomDomainAndWait({
|
||
accountId,
|
||
bucket: bucketName,
|
||
token: apiToken,
|
||
zoneId,
|
||
domain,
|
||
pollInterval: 2500,
|
||
timeoutMs: 120000,
|
||
});
|
||
customDomainStatus = custom?.status || "unknown";
|
||
if (custom?.active && custom?.url) {
|
||
publicBaseUrl = custom.url;
|
||
domainType = "custom";
|
||
try {
|
||
await setManagedDomain({
|
||
accountId,
|
||
bucket: bucketName,
|
||
token: apiToken,
|
||
enabled: false,
|
||
});
|
||
} catch (disableErr) {
|
||
console.warn("Failed to disable managed domain:", disableErr);
|
||
}
|
||
} else {
|
||
usedManagedFallback = true;
|
||
}
|
||
} catch (err) {
|
||
if (/already exists/i.test(err.message || "")) {
|
||
publicBaseUrl = `https://${domain}`;
|
||
domainType = "custom";
|
||
customDomainStatus = "active";
|
||
try {
|
||
await setManagedDomain({
|
||
accountId,
|
||
bucket: bucketName,
|
||
token: apiToken,
|
||
enabled: false,
|
||
});
|
||
} catch (disableErr) {
|
||
console.warn("Failed to disable managed domain:", disableErr);
|
||
}
|
||
} else {
|
||
console.warn("Failed to attach custom domain, falling back:", err);
|
||
usedManagedFallback = true;
|
||
customDomainStatus = "error";
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!publicBaseUrl) {
|
||
const managed = await setManagedDomain({
|
||
accountId,
|
||
bucket: bucketName,
|
||
token: apiToken,
|
||
enabled: true,
|
||
});
|
||
publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`;
|
||
domainType = "managed";
|
||
usedManagedFallback = true;
|
||
customDomainStatus = customDomainStatus === "skipped" ? "managed" : customDomainStatus;
|
||
}
|
||
|
||
const mergedEntry = {
|
||
bucket: bucketName,
|
||
publicBaseUrl,
|
||
domainType,
|
||
lastUpdated: Date.now(),
|
||
};
|
||
const updatedSettings = await saveR2Settings(
|
||
mergeBucketEntry(this.cloudflareSettings, npub, mergedEntry)
|
||
);
|
||
this.cloudflareSettings = updatedSettings;
|
||
return { entry: mergedEntry, usedManagedFallback, customDomainStatus };
|
||
}
|
||
|
||
async updateCloudflareBucketPreview() {
|
||
if (!this.cloudflareBucketPreview) {
|
||
return;
|
||
}
|
||
|
||
const el = this.cloudflareBucketPreview;
|
||
if (!this.cloudflareSettings) {
|
||
el.textContent = "Save your credentials to configure R2.";
|
||
return;
|
||
}
|
||
|
||
if (!this.pubkey) {
|
||
el.textContent = "Login to preview your R2 bucket.";
|
||
return;
|
||
}
|
||
|
||
const npub = this.safeEncodeNpub(this.pubkey);
|
||
if (!npub) {
|
||
el.textContent = "Unable to encode npub.";
|
||
return;
|
||
}
|
||
|
||
const entry = this.cloudflareSettings.buckets?.[npub];
|
||
if (!entry || !entry.publicBaseUrl) {
|
||
el.textContent = "Bucket will be auto-created on your next upload.";
|
||
return;
|
||
}
|
||
|
||
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
|
||
const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
|
||
const fullPreview = `${entry.bucket} • ${publicUrl}`;
|
||
|
||
let displayHostAndPath = truncateMiddle(publicUrl, 72);
|
||
try {
|
||
const parsed = new URL(publicUrl);
|
||
const cleanPath = parsed.pathname.replace(/^\//, "");
|
||
const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32);
|
||
displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`;
|
||
} catch (err) {
|
||
// ignore URL parse issues and fall back to the raw string
|
||
}
|
||
|
||
const truncatedBucket = truncateMiddle(entry.bucket, 28);
|
||
el.textContent = `${truncatedBucket} • ${displayHostAndPath}`;
|
||
el.setAttribute("title", fullPreview);
|
||
}
|
||
|
||
async handleCloudflareUploadSubmit() {
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to post a video.");
|
||
return;
|
||
}
|
||
|
||
const saved = await this.handleCloudflareSettingsSubmit({ quiet: true });
|
||
if (!saved) {
|
||
this.setCloudflareUploadStatus(
|
||
"Fix your R2 settings before uploading.",
|
||
"error"
|
||
);
|
||
return;
|
||
}
|
||
|
||
const title = (this.cloudflareTitleInput?.value || "").trim();
|
||
if (!title) {
|
||
this.setCloudflareUploadStatus("Title is required.", "error");
|
||
return;
|
||
}
|
||
|
||
const file = this.cloudflareFileInput?.files?.[0] || null;
|
||
if (!file) {
|
||
this.setCloudflareUploadStatus(
|
||
"Select a video or HLS file to upload.",
|
||
"error"
|
||
);
|
||
return;
|
||
}
|
||
|
||
const description = (this.cloudflareDescriptionInput?.value || "").trim();
|
||
const thumbnail = (this.cloudflareThumbnailInput?.value || "").trim();
|
||
const magnet = (this.cloudflareMagnetInput?.value || "").trim();
|
||
const ws = (this.cloudflareWsInput?.value || "").trim();
|
||
const xs = (this.cloudflareXsInput?.value || "").trim();
|
||
const enableComments = this.cloudflareEnableCommentsInput
|
||
? this.cloudflareEnableCommentsInput.checked
|
||
: true;
|
||
|
||
const accountId = (this.cloudflareSettings?.accountId || "").trim();
|
||
const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim();
|
||
const secretAccessKey = (
|
||
this.cloudflareSettings?.secretAccessKey || ""
|
||
).trim();
|
||
|
||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||
this.setCloudflareUploadStatus(
|
||
"Missing R2 credentials. Save them before uploading.",
|
||
"error"
|
||
);
|
||
return;
|
||
}
|
||
|
||
const npub = this.safeEncodeNpub(this.pubkey);
|
||
if (!npub) {
|
||
this.setCloudflareUploadStatus("Unable to encode npub.", "error");
|
||
return;
|
||
}
|
||
|
||
this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info");
|
||
this.updateCloudflareProgress(0);
|
||
this.setCloudflareUploading(true);
|
||
|
||
let bucketResult = null;
|
||
try {
|
||
bucketResult = await this.ensureBucketConfigForNpub(npub);
|
||
} catch (err) {
|
||
console.error("Failed to prepare R2 bucket:", err);
|
||
this.setCloudflareUploadStatus(
|
||
err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.",
|
||
"error"
|
||
);
|
||
this.setCloudflareUploading(false);
|
||
this.updateCloudflareProgress(Number.NaN);
|
||
return;
|
||
}
|
||
|
||
const bucketEntry =
|
||
bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub];
|
||
|
||
if (!bucketEntry || !bucketEntry.publicBaseUrl) {
|
||
this.setCloudflareUploadStatus(
|
||
"Bucket is missing a public domain. Check your Cloudflare settings.",
|
||
"error"
|
||
);
|
||
this.setCloudflareUploading(false);
|
||
this.updateCloudflareProgress(Number.NaN);
|
||
return;
|
||
}
|
||
|
||
let statusMessage = `Uploading to ${bucketEntry.bucket}…`;
|
||
if (bucketResult?.usedManagedFallback) {
|
||
const baseDomain = this.cloudflareSettings?.baseDomain || "";
|
||
if (baseDomain) {
|
||
const customStatus = bucketResult?.customDomainStatus
|
||
? ` (custom domain status: ${bucketResult.customDomainStatus})`
|
||
: "";
|
||
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`;
|
||
} else {
|
||
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`;
|
||
}
|
||
}
|
||
|
||
this.setCloudflareUploadStatus(
|
||
statusMessage,
|
||
bucketResult?.usedManagedFallback ? "warning" : "info"
|
||
);
|
||
|
||
const key = buildR2Key(npub, file);
|
||
const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key);
|
||
|
||
try {
|
||
const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
|
||
|
||
await multipartUpload({
|
||
s3,
|
||
bucket: bucketEntry.bucket,
|
||
key,
|
||
file,
|
||
contentType: file.type,
|
||
onProgress: (fraction) => {
|
||
this.updateCloudflareProgress(fraction);
|
||
},
|
||
});
|
||
|
||
const payload = {
|
||
title,
|
||
url: publicUrl,
|
||
magnet,
|
||
thumbnail,
|
||
description,
|
||
ws,
|
||
xs,
|
||
enableComments,
|
||
};
|
||
|
||
const published = await this.publishVideoNote(payload, {
|
||
onSuccess: () => {
|
||
this.resetCloudflareUploadForm();
|
||
},
|
||
});
|
||
|
||
if (published) {
|
||
this.setCloudflareUploadStatus(
|
||
`Published ${publicUrl}`,
|
||
"success"
|
||
);
|
||
}
|
||
} catch (err) {
|
||
console.error("Cloudflare upload failed:", err);
|
||
this.setCloudflareUploadStatus(
|
||
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
|
||
"error"
|
||
);
|
||
} finally {
|
||
this.setCloudflareUploading(false);
|
||
this.updateCloudflareProgress(Number.NaN);
|
||
await this.updateCloudflareBucketPreview();
|
||
}
|
||
}
|
||
|
||
renderSavedProfiles() {
|
||
const fallbackAvatar = "assets/svg/default-profile.svg";
|
||
const normalizedActive = this.normalizeHexPubkey(
|
||
this.activeProfilePubkey
|
||
);
|
||
const entriesNeedingFetch = new Set();
|
||
|
||
const resolveMeta = (entry) => {
|
||
if (!entry || typeof entry !== "object") {
|
||
return {
|
||
name: "",
|
||
picture: fallbackAvatar,
|
||
npub: null,
|
||
};
|
||
}
|
||
|
||
const normalizedPubkey = this.normalizeHexPubkey(entry.pubkey);
|
||
let cacheEntry = null;
|
||
if (normalizedPubkey) {
|
||
cacheEntry = this.getProfileCacheEntry(normalizedPubkey);
|
||
}
|
||
const cachedProfile = cacheEntry?.profile || {};
|
||
|
||
const hasStoredName =
|
||
typeof entry.name === "string" && entry.name.trim().length > 0;
|
||
const hasStoredPicture =
|
||
typeof entry.picture === "string" && entry.picture.trim().length > 0;
|
||
|
||
if (
|
||
!cacheEntry &&
|
||
normalizedPubkey &&
|
||
(!hasStoredName || !hasStoredPicture)
|
||
) {
|
||
entriesNeedingFetch.add(normalizedPubkey);
|
||
}
|
||
|
||
let resolvedNpub =
|
||
typeof entry.npub === "string" && entry.npub.trim()
|
||
? entry.npub.trim()
|
||
: null;
|
||
if (!resolvedNpub && entry.pubkey) {
|
||
resolvedNpub = this.safeEncodeNpub(entry.pubkey);
|
||
}
|
||
|
||
return {
|
||
name: cachedProfile.name || entry.name || "",
|
||
picture: cachedProfile.picture || entry.picture || fallbackAvatar,
|
||
npub: resolvedNpub,
|
||
};
|
||
};
|
||
|
||
const savedEntries = Array.isArray(this.savedProfiles)
|
||
? this.savedProfiles.filter((entry) => entry && entry.pubkey)
|
||
: [];
|
||
|
||
let activeEntry = null;
|
||
if (normalizedActive) {
|
||
activeEntry = savedEntries.find(
|
||
(entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedActive
|
||
);
|
||
}
|
||
if (!activeEntry && savedEntries.length) {
|
||
activeEntry = savedEntries[0];
|
||
}
|
||
|
||
const activeMeta = activeEntry ? resolveMeta(activeEntry) : null;
|
||
const hasActiveProfile = Boolean(activeEntry && activeMeta);
|
||
const activeNameFallback = activeMeta?.npub
|
||
? truncateMiddle(activeMeta.npub, 32)
|
||
: "Saved profile";
|
||
const activeDisplayName = hasActiveProfile
|
||
? activeMeta.name?.trim() || activeNameFallback
|
||
: "No active profile";
|
||
const activeAvatarSrc = hasActiveProfile
|
||
? activeMeta.picture || fallbackAvatar
|
||
: fallbackAvatar;
|
||
|
||
if (this.profileModalName) {
|
||
this.profileModalName.textContent = activeDisplayName;
|
||
}
|
||
|
||
if (this.profileModalAvatar instanceof HTMLImageElement) {
|
||
if (this.profileModalAvatar.src !== activeAvatarSrc) {
|
||
this.profileModalAvatar.src = activeAvatarSrc;
|
||
}
|
||
this.profileModalAvatar.alt = hasActiveProfile
|
||
? `${activeDisplayName} avatar`
|
||
: "Default profile avatar";
|
||
} else if (this.profileModalAvatar) {
|
||
this.profileModalAvatar.setAttribute("data-avatar-src", activeAvatarSrc);
|
||
}
|
||
|
||
if (this.profileModalNpub) {
|
||
if (hasActiveProfile && activeMeta?.npub) {
|
||
this.profileModalNpub.textContent = truncateMiddle(activeMeta.npub, 48);
|
||
} else if (hasActiveProfile) {
|
||
this.profileModalNpub.textContent = "npub unavailable";
|
||
} else {
|
||
this.profileModalNpub.textContent = "Link a profile to get started";
|
||
}
|
||
}
|
||
|
||
if (this.profileChannelLink) {
|
||
if (hasActiveProfile && activeMeta?.npub) {
|
||
const encodedNpub = activeMeta.npub;
|
||
this.profileChannelLink.href = `#view=channel-profile&npub=${encodeURIComponent(
|
||
encodedNpub
|
||
)}`;
|
||
this.profileChannelLink.dataset.targetNpub = encodedNpub;
|
||
this.profileChannelLink.classList.remove("hidden");
|
||
this.profileChannelLink.setAttribute("aria-hidden", "false");
|
||
} else {
|
||
this.profileChannelLink.classList.add("hidden");
|
||
this.profileChannelLink.removeAttribute("href");
|
||
delete this.profileChannelLink.dataset.targetNpub;
|
||
this.profileChannelLink.setAttribute("aria-hidden", "true");
|
||
}
|
||
}
|
||
|
||
if (this.profileAvatar instanceof HTMLImageElement) {
|
||
if (this.profileAvatar.src !== activeAvatarSrc) {
|
||
this.profileAvatar.src = activeAvatarSrc;
|
||
}
|
||
this.profileAvatar.alt = hasActiveProfile
|
||
? `${activeDisplayName} avatar`
|
||
: this.profileAvatar.alt || "Profile avatar";
|
||
}
|
||
|
||
const listEl = this.profileSwitcherList;
|
||
if (listEl instanceof HTMLElement) {
|
||
listEl.innerHTML = "";
|
||
let normalizedSelection = this.normalizeHexPubkey(
|
||
this.profileSwitcherSelectionPubkey
|
||
);
|
||
if (normalizedSelection && normalizedSelection === normalizedActive) {
|
||
normalizedSelection = null;
|
||
this.profileSwitcherSelectionPubkey = null;
|
||
}
|
||
const entriesToRender = savedEntries.filter((entry) => {
|
||
const normalized = this.normalizeHexPubkey(entry.pubkey);
|
||
return normalized && normalized !== normalizedActive;
|
||
});
|
||
|
||
if (!entriesToRender.length) {
|
||
listEl.setAttribute("data-profile-switcher-empty", "true");
|
||
const helper = document.createElement("p");
|
||
helper.className = "profile-switcher__empty text-sm text-gray-400";
|
||
helper.textContent = "No other profiles saved yet.";
|
||
helper.setAttribute("role", "note");
|
||
listEl.appendChild(helper);
|
||
} else {
|
||
listEl.removeAttribute("data-profile-switcher-empty");
|
||
|
||
entriesToRender.forEach((entry) => {
|
||
const meta = resolveMeta(entry);
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.classList.add("profile-card");
|
||
button.dataset.pubkey = entry.pubkey;
|
||
if (meta.npub) {
|
||
button.dataset.npub = meta.npub;
|
||
}
|
||
if (entry.authType) {
|
||
button.dataset.authType = entry.authType;
|
||
}
|
||
|
||
const normalizedPubkey = this.normalizeHexPubkey(entry.pubkey);
|
||
const isSelected =
|
||
normalizedSelection && normalizedPubkey === normalizedSelection;
|
||
if (isSelected) {
|
||
button.classList.add("profile-card--active");
|
||
button.setAttribute("aria-pressed", "true");
|
||
} else {
|
||
button.setAttribute("aria-pressed", "false");
|
||
}
|
||
|
||
const avatarSpan = document.createElement("span");
|
||
avatarSpan.className = "profile-card__avatar";
|
||
const avatarImg = document.createElement("img");
|
||
avatarImg.src = meta.picture || fallbackAvatar;
|
||
const cardDisplayName =
|
||
meta.name?.trim() ||
|
||
(meta.npub ? truncateMiddle(meta.npub, 32) : "Saved profile");
|
||
avatarImg.alt = `${cardDisplayName} avatar`;
|
||
avatarSpan.appendChild(avatarImg);
|
||
|
||
const metaSpan = document.createElement("span");
|
||
metaSpan.className = "profile-card__meta";
|
||
|
||
const topLine = document.createElement("span");
|
||
topLine.className = "profile-card__topline";
|
||
|
||
const label = document.createElement("span");
|
||
label.className = "profile-card__label";
|
||
label.textContent =
|
||
entry.authType === "nsec" ? "Direct key" : "Saved profile";
|
||
|
||
const action = document.createElement("span");
|
||
action.className = "profile-card__action";
|
||
action.setAttribute("aria-hidden", "true");
|
||
action.textContent = isSelected ? "Selected" : "Switch";
|
||
|
||
topLine.append(label, action);
|
||
|
||
const nameSpan = document.createElement("span");
|
||
nameSpan.className = "profile-card__name";
|
||
nameSpan.textContent = cardDisplayName;
|
||
|
||
const npubSpan = document.createElement("span");
|
||
npubSpan.className = "profile-card__npub";
|
||
npubSpan.textContent = meta.npub
|
||
? truncateMiddle(meta.npub, 48)
|
||
: "npub unavailable";
|
||
|
||
metaSpan.append(topLine, nameSpan, npubSpan);
|
||
button.append(avatarSpan, metaSpan);
|
||
|
||
const ariaLabel = isSelected
|
||
? `${cardDisplayName} selected`
|
||
: `Switch to ${cardDisplayName}`;
|
||
button.setAttribute("aria-label", ariaLabel);
|
||
|
||
const activateProfile = async (event) => {
|
||
if (event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
|
||
if (button.dataset.loading === "true") {
|
||
return;
|
||
}
|
||
|
||
button.dataset.loading = "true";
|
||
button.setAttribute("aria-busy", "true");
|
||
|
||
try {
|
||
await this.switchProfile(entry.pubkey);
|
||
} catch (error) {
|
||
console.error("Failed to switch profile:", error);
|
||
} finally {
|
||
button.dataset.loading = "false";
|
||
button.setAttribute("aria-busy", "false");
|
||
}
|
||
};
|
||
|
||
button.addEventListener("click", activateProfile);
|
||
button.addEventListener("keydown", (event) => {
|
||
const key = event?.key;
|
||
if (key === "Enter" || key === " " || key === "Spacebar") {
|
||
activateProfile(event);
|
||
}
|
||
});
|
||
|
||
listEl.appendChild(button);
|
||
});
|
||
}
|
||
|
||
this.updateProfileModalFocusables();
|
||
} else {
|
||
this.updateProfileModalFocusables();
|
||
}
|
||
|
||
if (entriesNeedingFetch.size) {
|
||
this.batchFetchProfiles(entriesNeedingFetch);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* (Optional) Initialize a separate profile modal (profile-modal.html).
|
||
*/
|
||
async initProfileModal() {
|
||
try {
|
||
console.log("Starting profile modal initialization...");
|
||
const resp = await fetch("components/profile-modal.html");
|
||
if (!resp.ok) {
|
||
// If you don't have a profile modal, comment this entire method out.
|
||
throw new Error(`HTTP error! status: ${resp.status}`);
|
||
}
|
||
const html = await resp.text();
|
||
|
||
const modalContainer = document.getElementById("modalContainer");
|
||
if (!modalContainer) {
|
||
throw new Error("Modal container element not found!");
|
||
}
|
||
const wrapper = document.createElement("div");
|
||
wrapper.innerHTML = html;
|
||
removeTrackingScripts(wrapper);
|
||
modalContainer.appendChild(wrapper);
|
||
|
||
// Now references
|
||
this.profileModal = document.getElementById("profileModal") || null;
|
||
this.closeProfileModal =
|
||
document.getElementById("closeProfileModal") || null;
|
||
this.profileLogoutBtn =
|
||
document.getElementById("profileLogoutBtn") || null;
|
||
this.profileModalAvatar =
|
||
document.getElementById("profileModalAvatar") || null;
|
||
this.profileModalName =
|
||
document.getElementById("profileModalName") || null;
|
||
this.profileModalNpub =
|
||
document.getElementById("profileModalNpub") || null;
|
||
this.profileChannelLink =
|
||
document.getElementById("profileChannelLink") || null;
|
||
this.profileSwitcherList =
|
||
document.getElementById("profileSwitcherList") || null;
|
||
this.profileAddAccountBtn =
|
||
document.getElementById("profileAddAccountBtn") || null;
|
||
const topLevelProfileAvatar =
|
||
document.getElementById("profileAvatar") || null;
|
||
if (topLevelProfileAvatar) {
|
||
this.profileAvatar = topLevelProfileAvatar;
|
||
}
|
||
this.profileNavButtons.account =
|
||
document.getElementById("profileNavAccount") || null;
|
||
this.profileNavButtons.relays =
|
||
document.getElementById("profileNavRelays") || null;
|
||
this.profileNavButtons.blocked =
|
||
document.getElementById("profileNavBlocked") || null;
|
||
this.profileNavButtons.history =
|
||
document.getElementById("profileNavHistory") || null;
|
||
this.profileNavButtons.admin =
|
||
document.getElementById("profileNavAdmin") || null;
|
||
this.profilePaneElements.account =
|
||
document.getElementById("profilePaneAccount") || null;
|
||
this.profilePaneElements.relays =
|
||
document.getElementById("profilePaneRelays") || null;
|
||
this.profilePaneElements.blocked =
|
||
document.getElementById("profilePaneBlocked") || null;
|
||
this.profilePaneElements.history =
|
||
document.getElementById("profilePaneHistory") || null;
|
||
this.profilePaneElements.admin =
|
||
document.getElementById("profilePaneAdmin") || null;
|
||
this.profileRelayList = document.getElementById("relayList") || null;
|
||
this.profileBlockedList = document.getElementById("blockedList") || null;
|
||
this.profileBlockedEmpty =
|
||
document.getElementById("blockedEmpty") || null;
|
||
this.profileBlockedInput =
|
||
document.getElementById("blockedInput") || null;
|
||
this.profileAddBlockedBtn =
|
||
document.getElementById("addBlockedBtn") || null;
|
||
this.profileRelayInput = document.getElementById("relayInput") || null;
|
||
this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null;
|
||
this.profileRestoreRelaysBtn =
|
||
document.getElementById("restoreRelaysBtn") || null;
|
||
if (!this.profileHistoryRenderer) {
|
||
this.profileHistoryRenderer = createWatchHistoryRenderer({
|
||
viewSelector: "#profilePaneHistory",
|
||
gridSelector: "#profileHistoryGrid",
|
||
loadingSelector: "#profileHistoryLoading",
|
||
statusSelector: "#profileHistoryStatus",
|
||
emptySelector: "#profileHistoryEmpty",
|
||
sentinelSelector: "#profileHistorySentinel",
|
||
scrollContainerSelector: "#profileHistoryScroll",
|
||
emptyCopy: "You haven’t watched any videos yet.",
|
||
getActor: async () => this.pubkey || window.app?.pubkey || undefined,
|
||
});
|
||
}
|
||
this.adminModeratorsSection =
|
||
document.getElementById("adminModeratorsSection") || null;
|
||
this.adminModeratorsEmpty =
|
||
document.getElementById("adminModeratorsEmpty") || null;
|
||
this.adminModeratorList =
|
||
document.getElementById("adminModeratorList") || null;
|
||
this.adminModeratorInput =
|
||
document.getElementById("adminModeratorInput") || null;
|
||
this.adminAddModeratorBtn =
|
||
document.getElementById("adminAddModeratorBtn") || null;
|
||
this.adminWhitelistSection =
|
||
document.getElementById("adminWhitelistSection") || null;
|
||
this.adminWhitelistEmpty =
|
||
document.getElementById("adminWhitelistEmpty") || null;
|
||
this.adminWhitelistList =
|
||
document.getElementById("adminWhitelistList") || null;
|
||
this.adminWhitelistInput =
|
||
document.getElementById("adminWhitelistInput") || null;
|
||
this.adminAddWhitelistBtn =
|
||
document.getElementById("adminAddWhitelistBtn") || null;
|
||
this.adminBlacklistSection =
|
||
document.getElementById("adminBlacklistSection") || null;
|
||
this.adminBlacklistEmpty =
|
||
document.getElementById("adminBlacklistEmpty") || null;
|
||
this.adminBlacklistList =
|
||
document.getElementById("adminBlacklistList") || null;
|
||
this.adminBlacklistInput =
|
||
document.getElementById("adminBlacklistInput") || null;
|
||
this.adminAddBlacklistBtn =
|
||
document.getElementById("adminAddBlacklistBtn") || null;
|
||
|
||
// Wire up
|
||
if (this.closeProfileModal && !this.closeProfileModal.dataset.bound) {
|
||
this.closeProfileModal.dataset.bound = "true";
|
||
this.closeProfileModal.addEventListener("click", () => {
|
||
this.hideProfileModal();
|
||
});
|
||
}
|
||
if (this.profileLogoutBtn && !this.profileLogoutBtn.dataset.bound) {
|
||
this.profileLogoutBtn.dataset.bound = "true";
|
||
this.profileLogoutBtn.addEventListener("click", () => {
|
||
this.logout();
|
||
this.hideProfileModal();
|
||
});
|
||
}
|
||
|
||
if (this.profileChannelLink && !this.profileChannelLink.dataset.bound) {
|
||
this.profileChannelLink.dataset.bound = "true";
|
||
this.profileChannelLink.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
const targetNpub = this.profileChannelLink?.dataset?.targetNpub;
|
||
if (!targetNpub) {
|
||
return;
|
||
}
|
||
this.hideProfileModal();
|
||
window.location.hash = `#view=channel-profile&npub=${targetNpub}`;
|
||
});
|
||
}
|
||
|
||
if (
|
||
this.profileAddAccountBtn &&
|
||
this.profileAddAccountBtn.dataset.bound !== "true"
|
||
) {
|
||
this.profileAddAccountBtn.dataset.bound = "true";
|
||
this.profileAddAccountBtn.addEventListener("click", () => {
|
||
this.handleAddProfile();
|
||
});
|
||
}
|
||
|
||
Object.entries(this.profileNavButtons).forEach(([name, button]) => {
|
||
if (!button || button.dataset.navBound === "true") {
|
||
return;
|
||
}
|
||
button.dataset.navBound = "true";
|
||
button.addEventListener("click", () => {
|
||
this.selectProfilePane(name);
|
||
});
|
||
});
|
||
|
||
if (this.profileAddRelayBtn && !this.profileAddRelayBtn.dataset.bound) {
|
||
this.profileAddRelayBtn.dataset.bound = "true";
|
||
this.profileAddRelayBtn.addEventListener("click", () => {
|
||
this.showSuccess("Relay management coming soon.");
|
||
});
|
||
}
|
||
if (
|
||
this.profileRestoreRelaysBtn &&
|
||
this.profileRestoreRelaysBtn.dataset.bound !== "true"
|
||
) {
|
||
this.profileRestoreRelaysBtn.dataset.bound = "true";
|
||
this.profileRestoreRelaysBtn.addEventListener("click", () => {
|
||
this.showSuccess("Relay management coming soon.");
|
||
});
|
||
}
|
||
|
||
if (
|
||
this.profileAddBlockedBtn &&
|
||
this.profileAddBlockedBtn.dataset.bound !== "true"
|
||
) {
|
||
this.profileAddBlockedBtn.dataset.bound = "true";
|
||
this.profileAddBlockedBtn.addEventListener("click", () => {
|
||
this.handleAddBlockedCreator();
|
||
});
|
||
}
|
||
if (
|
||
this.profileBlockedInput &&
|
||
this.profileBlockedInput.dataset.bound !== "true"
|
||
) {
|
||
this.profileBlockedInput.dataset.bound = "true";
|
||
this.profileBlockedInput.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
this.handleAddBlockedCreator();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (
|
||
this.adminAddModeratorBtn &&
|
||
this.adminAddModeratorBtn.dataset.bound !== "true"
|
||
) {
|
||
this.adminAddModeratorBtn.dataset.bound = "true";
|
||
this.adminAddModeratorBtn.addEventListener("click", () => {
|
||
this.handleAddModerator();
|
||
});
|
||
}
|
||
if (
|
||
this.adminModeratorInput &&
|
||
this.adminModeratorInput.dataset.bound !== "true"
|
||
) {
|
||
this.adminModeratorInput.dataset.bound = "true";
|
||
this.adminModeratorInput.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
this.handleAddModerator();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (
|
||
this.adminAddWhitelistBtn &&
|
||
this.adminAddWhitelistBtn.dataset.bound !== "true"
|
||
) {
|
||
this.adminAddWhitelistBtn.dataset.bound = "true";
|
||
this.adminAddWhitelistBtn.addEventListener("click", () => {
|
||
this.handleAdminListMutation("whitelist", "add");
|
||
});
|
||
}
|
||
if (
|
||
this.adminWhitelistInput &&
|
||
this.adminWhitelistInput.dataset.bound !== "true"
|
||
) {
|
||
this.adminWhitelistInput.dataset.bound = "true";
|
||
this.adminWhitelistInput.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
this.handleAdminListMutation("whitelist", "add");
|
||
}
|
||
});
|
||
}
|
||
|
||
if (
|
||
this.adminAddBlacklistBtn &&
|
||
this.adminAddBlacklistBtn.dataset.bound !== "true"
|
||
) {
|
||
this.adminAddBlacklistBtn.dataset.bound = "true";
|
||
this.adminAddBlacklistBtn.addEventListener("click", () => {
|
||
this.handleAdminListMutation("blacklist", "add");
|
||
});
|
||
}
|
||
if (
|
||
this.adminBlacklistInput &&
|
||
this.adminBlacklistInput.dataset.bound !== "true"
|
||
) {
|
||
this.adminBlacklistInput.dataset.bound = "true";
|
||
this.adminBlacklistInput.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
this.handleAdminListMutation("blacklist", "add");
|
||
}
|
||
});
|
||
}
|
||
|
||
this.selectProfilePane("account");
|
||
this.populateProfileRelays();
|
||
this.populateBlockedList();
|
||
await this.refreshAdminPaneState();
|
||
|
||
this.renderSavedProfiles();
|
||
|
||
console.log("Profile modal initialization successful");
|
||
return true;
|
||
} catch (error) {
|
||
console.error("initProfileModal failed:", error);
|
||
// Not critical if missing
|
||
return false;
|
||
}
|
||
}
|
||
|
||
selectProfilePane(name = "account") {
|
||
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
|
||
const previous = this.activeProfilePane;
|
||
const availableKeys = Object.keys(this.profilePaneElements).filter((key) => {
|
||
const pane = this.profilePaneElements[key];
|
||
if (!(pane instanceof HTMLElement)) {
|
||
return false;
|
||
}
|
||
const button = this.profileNavButtons[key];
|
||
if (button instanceof HTMLElement && button.classList.contains("hidden")) {
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
const fallbackTarget = availableKeys.includes("account")
|
||
? "account"
|
||
: availableKeys[0] || "account";
|
||
const target = availableKeys.includes(normalized)
|
||
? normalized
|
||
: fallbackTarget;
|
||
|
||
if (previous === "history" && target !== "history") {
|
||
try {
|
||
this.profileHistoryRenderer?.pause();
|
||
} catch (error) {
|
||
console.warn("[profileModal] Failed to pause history renderer:", error);
|
||
}
|
||
}
|
||
|
||
Object.entries(this.profilePaneElements).forEach(([key, pane]) => {
|
||
if (!(pane instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
const isActive = key === target;
|
||
pane.classList.toggle("hidden", !isActive);
|
||
pane.setAttribute("aria-hidden", (!isActive).toString());
|
||
});
|
||
|
||
Object.entries(this.profileNavButtons).forEach(([key, button]) => {
|
||
if (!(button instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
const isActive = key === target;
|
||
button.setAttribute("aria-selected", isActive ? "true" : "false");
|
||
button.classList.toggle("bg-gray-800", isActive);
|
||
button.classList.toggle("text-white", isActive);
|
||
button.classList.toggle("text-gray-400", !isActive);
|
||
});
|
||
|
||
this.updateProfileModalFocusables();
|
||
this.activeProfilePane = target;
|
||
|
||
if (target === "history") {
|
||
void this.populateProfileWatchHistory();
|
||
}
|
||
}
|
||
|
||
updateProfileModalFocusables() {
|
||
if (!this.profileModal) {
|
||
this.profileModalFocusables = [];
|
||
return;
|
||
}
|
||
|
||
const focusableSelectors =
|
||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
|
||
const candidates = Array.from(
|
||
this.profileModal.querySelectorAll(focusableSelectors)
|
||
);
|
||
|
||
this.profileModalFocusables = candidates.filter((el) => {
|
||
if (!(el instanceof HTMLElement)) {
|
||
return false;
|
||
}
|
||
if (el.hasAttribute("disabled")) {
|
||
return false;
|
||
}
|
||
if (el.getAttribute("aria-hidden") === "true") {
|
||
return false;
|
||
}
|
||
if (el.offsetParent === null && el !== document.activeElement) {
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
async openProfileModal() {
|
||
if (!this.profileModal) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.refreshAdminPaneState();
|
||
} catch (error) {
|
||
console.error("Failed to refresh admin pane while opening profile modal:", error);
|
||
}
|
||
|
||
this.activeProfilePane = null;
|
||
this.selectProfilePane("account");
|
||
this.populateProfileRelays();
|
||
try {
|
||
await userBlocks.ensureLoaded(this.pubkey);
|
||
} catch (error) {
|
||
console.warn("Failed to refresh user block list while opening profile modal:", error);
|
||
}
|
||
this.populateBlockedList();
|
||
|
||
this.profileModal.classList.remove("hidden");
|
||
this.profileModal.setAttribute("aria-hidden", "false");
|
||
|
||
this.lastFocusedBeforeProfileModal =
|
||
document.activeElement instanceof HTMLElement
|
||
? document.activeElement
|
||
: null;
|
||
|
||
this.updateProfileModalFocusables();
|
||
|
||
const initialTarget =
|
||
this.profileNavButtons.account instanceof HTMLElement
|
||
? this.profileNavButtons.account
|
||
: this.profileModal;
|
||
|
||
window.requestAnimationFrame(() => {
|
||
if (initialTarget && typeof initialTarget.focus === "function") {
|
||
initialTarget.focus();
|
||
}
|
||
});
|
||
|
||
if (!this.boundProfileModalKeydown) {
|
||
this.boundProfileModalKeydown = (event) => {
|
||
if (!this.profileModal || this.profileModal.classList.contains("hidden")) {
|
||
return;
|
||
}
|
||
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
this.hideProfileModal();
|
||
return;
|
||
}
|
||
|
||
if (event.key !== "Tab") {
|
||
return;
|
||
}
|
||
|
||
this.updateProfileModalFocusables();
|
||
if (!this.profileModalFocusables.length) {
|
||
event.preventDefault();
|
||
if (typeof this.profileModal.focus === "function") {
|
||
this.profileModal.focus();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const first = this.profileModalFocusables[0];
|
||
const last = this.profileModalFocusables[this.profileModalFocusables.length - 1];
|
||
const active = document.activeElement;
|
||
|
||
if (event.shiftKey) {
|
||
if (active === first || !this.profileModal.contains(active)) {
|
||
event.preventDefault();
|
||
if (last && typeof last.focus === "function") {
|
||
last.focus();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (active === last) {
|
||
event.preventDefault();
|
||
if (first && typeof first.focus === "function") {
|
||
first.focus();
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
if (!this.boundProfileModalFocusIn) {
|
||
this.boundProfileModalFocusIn = (event) => {
|
||
if (
|
||
!this.profileModal ||
|
||
this.profileModal.classList.contains("hidden") ||
|
||
this.profileModal.contains(event.target)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
this.updateProfileModalFocusables();
|
||
const fallback = this.profileModalFocusables[0] || this.profileModal;
|
||
if (fallback && typeof fallback.focus === "function") {
|
||
fallback.focus();
|
||
}
|
||
};
|
||
}
|
||
|
||
this.profileModal.addEventListener("keydown", this.boundProfileModalKeydown);
|
||
document.addEventListener("focusin", this.boundProfileModalFocusIn);
|
||
}
|
||
|
||
hideProfileModal() {
|
||
if (!this.profileModal) {
|
||
return;
|
||
}
|
||
|
||
if (!this.profileModal.classList.contains("hidden")) {
|
||
this.profileModal.classList.add("hidden");
|
||
}
|
||
this.profileModal.setAttribute("aria-hidden", "true");
|
||
|
||
if (this.boundProfileModalKeydown) {
|
||
this.profileModal.removeEventListener(
|
||
"keydown",
|
||
this.boundProfileModalKeydown
|
||
);
|
||
}
|
||
if (this.boundProfileModalFocusIn) {
|
||
document.removeEventListener("focusin", this.boundProfileModalFocusIn);
|
||
}
|
||
|
||
this.closeAllMoreMenus();
|
||
|
||
try {
|
||
this.profileHistoryRenderer?.destroy();
|
||
} catch (error) {
|
||
console.warn(
|
||
"[profileModal] Failed to reset watch history renderer on close:",
|
||
error
|
||
);
|
||
}
|
||
|
||
this.activeProfilePane = null;
|
||
|
||
if (
|
||
this.lastFocusedBeforeProfileModal &&
|
||
typeof this.lastFocusedBeforeProfileModal.focus === "function"
|
||
) {
|
||
this.lastFocusedBeforeProfileModal.focus();
|
||
}
|
||
this.lastFocusedBeforeProfileModal = null;
|
||
}
|
||
|
||
populateProfileRelays(relayUrls) {
|
||
if (!this.profileRelayList) {
|
||
return;
|
||
}
|
||
|
||
const rawRelays =
|
||
Array.isArray(relayUrls)
|
||
? relayUrls
|
||
: Array.isArray(nostrClient.relays)
|
||
? nostrClient.relays
|
||
: Array.from(nostrClient.relays || []);
|
||
|
||
const relays = rawRelays
|
||
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
|
||
.filter((relay) => relay.length > 0);
|
||
|
||
this.profileRelayList.innerHTML = "";
|
||
|
||
if (!relays.length) {
|
||
const emptyState = document.createElement("li");
|
||
emptyState.className =
|
||
"rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400";
|
||
emptyState.textContent = "No relays configured.";
|
||
this.profileRelayList.appendChild(emptyState);
|
||
return;
|
||
}
|
||
|
||
relays.forEach((relayUrl) => {
|
||
const item = document.createElement("li");
|
||
item.className =
|
||
"flex items-start justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3";
|
||
|
||
const info = document.createElement("div");
|
||
info.className = "flex-1 min-w-0";
|
||
|
||
const urlEl = document.createElement("p");
|
||
urlEl.className = "text-sm font-medium text-gray-100 break-all";
|
||
urlEl.textContent = relayUrl;
|
||
|
||
const statusEl = document.createElement("p");
|
||
statusEl.className = "mt-1 text-xs text-gray-400";
|
||
statusEl.textContent = "Active";
|
||
|
||
info.appendChild(urlEl);
|
||
info.appendChild(statusEl);
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "flex items-center gap-2";
|
||
|
||
const editBtn = document.createElement("button");
|
||
editBtn.type = "button";
|
||
editBtn.className =
|
||
"px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
|
||
editBtn.textContent = "Edit";
|
||
editBtn.addEventListener("click", () => {
|
||
this.showSuccess("Relay management coming soon.");
|
||
});
|
||
|
||
const removeBtn = document.createElement("button");
|
||
removeBtn.type = "button";
|
||
removeBtn.className =
|
||
"px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
|
||
removeBtn.textContent = "Remove";
|
||
removeBtn.addEventListener("click", () => {
|
||
this.showSuccess("Relay management coming soon.");
|
||
});
|
||
|
||
actions.appendChild(editBtn);
|
||
actions.appendChild(removeBtn);
|
||
|
||
item.appendChild(info);
|
||
item.appendChild(actions);
|
||
|
||
this.profileRelayList.appendChild(item);
|
||
});
|
||
}
|
||
|
||
populateBlockedList(blocked = null) {
|
||
if (!this.profileBlockedList || !this.profileBlockedEmpty) {
|
||
return;
|
||
}
|
||
|
||
const sourceEntries =
|
||
Array.isArray(blocked) && blocked.length
|
||
? blocked
|
||
: userBlocks.getBlockedPubkeys();
|
||
|
||
const normalizedEntries = [];
|
||
const pushEntry = (hex, label) => {
|
||
if (!hex || !label) {
|
||
return;
|
||
}
|
||
normalizedEntries.push({ hex, label });
|
||
};
|
||
|
||
sourceEntries.forEach((entry) => {
|
||
if (typeof entry === "string") {
|
||
const trimmed = entry.trim();
|
||
if (!trimmed) {
|
||
return;
|
||
}
|
||
|
||
if (trimmed.startsWith("npub1")) {
|
||
const decoded = this.safeDecodeNpub(trimmed);
|
||
if (!decoded) {
|
||
return;
|
||
}
|
||
const label = this.safeEncodeNpub(decoded) || trimmed;
|
||
pushEntry(decoded, label);
|
||
return;
|
||
}
|
||
|
||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||
const hex = trimmed.toLowerCase();
|
||
const label = this.safeEncodeNpub(hex) || hex;
|
||
pushEntry(hex, label);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (entry && typeof entry === "object") {
|
||
const candidateNpub =
|
||
typeof entry.npub === "string" ? entry.npub.trim() : "";
|
||
const candidateHex =
|
||
typeof entry.pubkey === "string" ? entry.pubkey.trim() : "";
|
||
|
||
if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) {
|
||
const normalizedHex = candidateHex.toLowerCase();
|
||
const label =
|
||
candidateNpub && candidateNpub.startsWith("npub1")
|
||
? candidateNpub
|
||
: this.safeEncodeNpub(normalizedHex) || normalizedHex;
|
||
pushEntry(normalizedHex, label);
|
||
return;
|
||
}
|
||
|
||
if (candidateNpub && candidateNpub.startsWith("npub1")) {
|
||
const decoded = this.safeDecodeNpub(candidateNpub);
|
||
if (!decoded) {
|
||
return;
|
||
}
|
||
const label = this.safeEncodeNpub(decoded) || candidateNpub;
|
||
pushEntry(decoded, label);
|
||
}
|
||
}
|
||
});
|
||
|
||
const deduped = [];
|
||
const seenHex = new Set();
|
||
normalizedEntries.forEach((entry) => {
|
||
if (!seenHex.has(entry.hex)) {
|
||
seenHex.add(entry.hex);
|
||
deduped.push(entry);
|
||
}
|
||
});
|
||
|
||
this.profileBlockedList.innerHTML = "";
|
||
|
||
if (!deduped.length) {
|
||
this.profileBlockedEmpty.classList.remove("hidden");
|
||
this.profileBlockedList.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
this.profileBlockedEmpty.classList.add("hidden");
|
||
this.profileBlockedList.classList.remove("hidden");
|
||
|
||
deduped.forEach(({ hex, label }) => {
|
||
const item = document.createElement("li");
|
||
item.className =
|
||
"flex items-center justify-between gap-4 rounded-lg bg-gray-800 px-4 py-3";
|
||
|
||
const info = document.createElement("div");
|
||
info.className = "min-w-0";
|
||
|
||
const title = document.createElement("p");
|
||
title.className = "text-sm font-medium text-gray-100 break-all";
|
||
title.textContent = label;
|
||
|
||
info.appendChild(title);
|
||
|
||
const actionBtn = document.createElement("button");
|
||
actionBtn.type = "button";
|
||
actionBtn.className =
|
||
"px-3 py-1 rounded-md bg-gray-700 text-xs font-medium text-gray-100 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
|
||
actionBtn.textContent = "Remove";
|
||
actionBtn.dataset.blockedHex = hex;
|
||
actionBtn.addEventListener("click", () => {
|
||
this.handleRemoveBlockedCreator(hex);
|
||
});
|
||
|
||
item.appendChild(info);
|
||
item.appendChild(actionBtn);
|
||
|
||
this.profileBlockedList.appendChild(item);
|
||
});
|
||
}
|
||
|
||
async populateProfileWatchHistory() {
|
||
if (!this.profileHistoryRenderer) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.profileHistoryRenderer.ensureInitialLoad();
|
||
this.profileHistoryRenderer.resume();
|
||
} catch (error) {
|
||
console.error(
|
||
"[profileModal] Failed to populate watch history pane:",
|
||
error
|
||
);
|
||
}
|
||
}
|
||
|
||
async handleAddProfile() {
|
||
if (!this.profileAddAccountBtn) {
|
||
return;
|
||
}
|
||
|
||
const button = this.profileAddAccountBtn;
|
||
if (button.dataset.loading === "true") {
|
||
return;
|
||
}
|
||
|
||
const titleEl = button.querySelector(".profile-switcher__addTitle");
|
||
const hintEl = button.querySelector(".profile-switcher__addHint");
|
||
const originalTitle = titleEl ? titleEl.textContent : "";
|
||
const originalHint = hintEl ? hintEl.textContent : "";
|
||
const originalAriaLabel = button.getAttribute("aria-label");
|
||
const originalDisabled = button.disabled;
|
||
|
||
const setLoadingState = (isLoading) => {
|
||
button.disabled = isLoading ? true : originalDisabled;
|
||
button.dataset.loading = isLoading ? "true" : "false";
|
||
button.setAttribute("aria-busy", isLoading ? "true" : "false");
|
||
if (isLoading) {
|
||
button.setAttribute("aria-disabled", "true");
|
||
} else if (originalDisabled) {
|
||
button.setAttribute("aria-disabled", "true");
|
||
} else {
|
||
button.removeAttribute("aria-disabled");
|
||
}
|
||
if (isLoading) {
|
||
if (titleEl) {
|
||
titleEl.textContent = "Connecting...";
|
||
}
|
||
if (hintEl) {
|
||
hintEl.textContent = "Check your extension";
|
||
}
|
||
button.setAttribute(
|
||
"aria-label",
|
||
"Connecting to your Nostr extension"
|
||
);
|
||
} else {
|
||
if (titleEl) {
|
||
titleEl.textContent = originalTitle;
|
||
}
|
||
if (hintEl) {
|
||
hintEl.textContent = originalHint;
|
||
}
|
||
if (originalAriaLabel === null) {
|
||
button.removeAttribute("aria-label");
|
||
} else {
|
||
button.setAttribute("aria-label", originalAriaLabel);
|
||
}
|
||
}
|
||
};
|
||
|
||
setLoadingState(true);
|
||
|
||
try {
|
||
const pubkey = await nostrClient.login({
|
||
allowAccountSelection: true,
|
||
});
|
||
|
||
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
|
||
if (!normalizedPubkey) {
|
||
throw new Error(
|
||
"Received an invalid public key from the Nostr extension."
|
||
);
|
||
}
|
||
|
||
const alreadySaved = this.savedProfiles.some(
|
||
(entry) =>
|
||
this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey
|
||
);
|
||
if (alreadySaved) {
|
||
this.showSuccess("That profile is already saved on this device.");
|
||
return;
|
||
}
|
||
|
||
const npub = this.safeEncodeNpub(normalizedPubkey) || "";
|
||
let profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
|
||
|
||
if (!profileMeta) {
|
||
await this.loadOwnProfile(normalizedPubkey);
|
||
profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
|
||
}
|
||
|
||
const name = profileMeta?.name || "";
|
||
const picture =
|
||
profileMeta?.picture || "assets/svg/default-profile.svg";
|
||
|
||
this.savedProfiles.push({
|
||
pubkey: normalizedPubkey,
|
||
npub,
|
||
name,
|
||
picture,
|
||
authType: "nip07",
|
||
});
|
||
|
||
this.persistSavedProfiles({ persistActive: false });
|
||
this.renderSavedProfiles();
|
||
|
||
this.showSuccess("Profile added. Select it when you're ready to switch.");
|
||
} catch (error) {
|
||
console.error("Failed to add profile via NIP-07:", error);
|
||
const message =
|
||
error && typeof error.message === "string" && error.message.trim()
|
||
? error.message.trim()
|
||
: "Couldn't add that profile. Please try again.";
|
||
this.showError(message);
|
||
} finally {
|
||
setLoadingState(false);
|
||
}
|
||
}
|
||
|
||
async handleAddBlockedCreator() {
|
||
if (!this.profileBlockedInput) {
|
||
return;
|
||
}
|
||
|
||
const rawValue = this.profileBlockedInput.value;
|
||
const trimmed = typeof rawValue === "string" ? rawValue.trim() : "";
|
||
|
||
if (!trimmed) {
|
||
this.showError("Enter an npub to block.");
|
||
return;
|
||
}
|
||
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to manage your block list.");
|
||
return;
|
||
}
|
||
|
||
const actorHex = this.pubkey;
|
||
let targetHex = "";
|
||
|
||
if (trimmed.startsWith("npub1")) {
|
||
targetHex = this.safeDecodeNpub(trimmed) || "";
|
||
if (!targetHex) {
|
||
this.showError("Invalid npub. Please double-check and try again.");
|
||
return;
|
||
}
|
||
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||
targetHex = trimmed.toLowerCase();
|
||
} else {
|
||
this.showError("Enter a valid npub or hex pubkey.");
|
||
return;
|
||
}
|
||
|
||
if (targetHex === actorHex) {
|
||
this.showError("You cannot block yourself.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await userBlocks.ensureLoaded(actorHex);
|
||
|
||
if (userBlocks.isBlocked(targetHex)) {
|
||
this.showSuccess("You already blocked this creator.");
|
||
} else {
|
||
await userBlocks.addBlock(targetHex, actorHex);
|
||
this.showSuccess(
|
||
"Creator blocked. You won't see their videos anymore."
|
||
);
|
||
}
|
||
|
||
this.profileBlockedInput.value = "";
|
||
this.populateBlockedList();
|
||
await this.loadVideos();
|
||
} catch (error) {
|
||
console.error("Failed to add creator to personal block list:", error);
|
||
const message =
|
||
error?.code === "nip04-missing"
|
||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||
: "Failed to update your block list. Please try again.";
|
||
this.showError(message);
|
||
}
|
||
}
|
||
|
||
async handleRemoveBlockedCreator(candidate) {
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to manage your block list.");
|
||
return;
|
||
}
|
||
|
||
let targetHex = "";
|
||
if (typeof candidate === "string") {
|
||
const trimmed = candidate.trim();
|
||
if (!trimmed) {
|
||
return;
|
||
}
|
||
|
||
if (trimmed.startsWith("npub1")) {
|
||
targetHex = this.safeDecodeNpub(trimmed) || "";
|
||
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||
targetHex = trimmed.toLowerCase();
|
||
}
|
||
}
|
||
|
||
if (!targetHex) {
|
||
console.warn("No valid pubkey to remove from block list:", candidate);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await userBlocks.ensureLoaded(this.pubkey);
|
||
|
||
if (!userBlocks.isBlocked(targetHex)) {
|
||
this.showSuccess("Creator already removed from your block list.");
|
||
} else {
|
||
await userBlocks.removeBlock(targetHex, this.pubkey);
|
||
this.showSuccess("Creator removed from your block list.");
|
||
}
|
||
|
||
this.populateBlockedList();
|
||
await this.loadVideos();
|
||
} catch (error) {
|
||
console.error(
|
||
"Failed to remove creator from personal block list:",
|
||
error
|
||
);
|
||
const message =
|
||
error?.code === "nip04-missing"
|
||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||
: "Failed to update your block list. Please try again.";
|
||
this.showError(message);
|
||
}
|
||
}
|
||
|
||
isAuthorBlocked(pubkey) {
|
||
return userBlocks.isBlocked(pubkey);
|
||
}
|
||
|
||
async refreshAdminPaneState() {
|
||
const adminNav = this.profileNavButtons.admin;
|
||
const adminPane = this.profilePaneElements.admin;
|
||
|
||
let loadError = null;
|
||
this.setAdminLoading(true);
|
||
this.showStatus("Fetching moderation filters…");
|
||
try {
|
||
await accessControl.ensureReady();
|
||
} catch (error) {
|
||
loadError = error;
|
||
}
|
||
|
||
const actorNpub = this.getCurrentUserNpub();
|
||
const canEdit = !!actorNpub && accessControl.canEditAdminLists(actorNpub);
|
||
const isSuperAdmin = !!actorNpub && accessControl.isSuperAdmin(actorNpub);
|
||
|
||
if (adminNav instanceof HTMLElement) {
|
||
adminNav.classList.toggle("hidden", !canEdit);
|
||
if (!canEdit) {
|
||
adminNav.setAttribute("aria-selected", "false");
|
||
}
|
||
}
|
||
|
||
if (adminPane instanceof HTMLElement) {
|
||
adminPane.classList.toggle("hidden", !canEdit);
|
||
adminPane.setAttribute("aria-hidden", (!canEdit).toString());
|
||
}
|
||
|
||
if (loadError) {
|
||
if (loadError?.code === "nostr-unavailable") {
|
||
console.info("Moderation lists are still syncing with relays.");
|
||
return;
|
||
}
|
||
|
||
console.error("Failed to load admin lists:", loadError);
|
||
this.showStatus(null);
|
||
this.showError("Unable to load moderation lists. Please try again.");
|
||
this.clearAdminLists();
|
||
this.setAdminLoading(false);
|
||
return;
|
||
}
|
||
|
||
if (!canEdit) {
|
||
this.clearAdminLists();
|
||
if (typeof actorNpub !== "string" || !actorNpub) {
|
||
this.currentUserNpub = null;
|
||
}
|
||
if (adminNav instanceof HTMLElement && adminNav.classList.contains("bg-gray-800")) {
|
||
this.selectProfilePane("account");
|
||
}
|
||
this.showStatus(null);
|
||
this.setAdminLoading(false);
|
||
return;
|
||
}
|
||
|
||
if (this.adminModeratorsSection instanceof HTMLElement) {
|
||
this.adminModeratorsSection.classList.toggle("hidden", !isSuperAdmin);
|
||
this.adminModeratorsSection.setAttribute("aria-hidden", (!isSuperAdmin).toString());
|
||
}
|
||
this.populateAdminLists();
|
||
this.showStatus(null);
|
||
this.setAdminLoading(false);
|
||
}
|
||
|
||
storeAdminEmptyMessages() {
|
||
const capture = (element) => {
|
||
if (element instanceof HTMLElement && !element.dataset.defaultMessage) {
|
||
element.dataset.defaultMessage = element.textContent || "";
|
||
}
|
||
};
|
||
|
||
capture(this.adminModeratorsEmpty);
|
||
capture(this.adminWhitelistEmpty);
|
||
capture(this.adminBlacklistEmpty);
|
||
}
|
||
|
||
setAdminLoading(isLoading) {
|
||
this.storeAdminEmptyMessages();
|
||
if (this.profilePaneElements.admin instanceof HTMLElement) {
|
||
this.profilePaneElements.admin.setAttribute(
|
||
"aria-busy",
|
||
isLoading ? "true" : "false"
|
||
);
|
||
}
|
||
|
||
const toggleMessage = (element, message) => {
|
||
if (!(element instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
if (isLoading) {
|
||
element.textContent = message;
|
||
element.classList.remove("hidden");
|
||
} else {
|
||
element.textContent = element.dataset.defaultMessage || element.textContent;
|
||
}
|
||
};
|
||
|
||
toggleMessage(this.adminModeratorsEmpty, "Loading moderators…");
|
||
toggleMessage(this.adminWhitelistEmpty, "Loading whitelist…");
|
||
toggleMessage(this.adminBlacklistEmpty, "Loading blacklist…");
|
||
}
|
||
|
||
clearAdminLists() {
|
||
this.storeAdminEmptyMessages();
|
||
if (this.adminModeratorList) {
|
||
this.adminModeratorList.innerHTML = "";
|
||
}
|
||
if (this.adminWhitelistList) {
|
||
this.adminWhitelistList.innerHTML = "";
|
||
}
|
||
if (this.adminBlacklistList) {
|
||
this.adminBlacklistList.innerHTML = "";
|
||
}
|
||
if (this.adminModeratorsEmpty instanceof HTMLElement) {
|
||
this.adminModeratorsEmpty.textContent =
|
||
this.adminModeratorsEmpty.dataset.defaultMessage ||
|
||
this.adminModeratorsEmpty.textContent;
|
||
this.adminModeratorsEmpty.classList.remove("hidden");
|
||
}
|
||
if (this.adminWhitelistEmpty instanceof HTMLElement) {
|
||
this.adminWhitelistEmpty.textContent =
|
||
this.adminWhitelistEmpty.dataset.defaultMessage ||
|
||
this.adminWhitelistEmpty.textContent;
|
||
this.adminWhitelistEmpty.classList.remove("hidden");
|
||
}
|
||
if (this.adminBlacklistEmpty instanceof HTMLElement) {
|
||
this.adminBlacklistEmpty.textContent =
|
||
this.adminBlacklistEmpty.dataset.defaultMessage ||
|
||
this.adminBlacklistEmpty.textContent;
|
||
this.adminBlacklistEmpty.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
renderAdminList(listEl, emptyEl, entries, options = {}) {
|
||
if (!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const { onRemove, removeLabel = "Remove", confirmMessage, removable = true } =
|
||
options;
|
||
|
||
listEl.innerHTML = "";
|
||
const values = Array.isArray(entries) ? [...entries] : [];
|
||
values.sort((a, b) => a.localeCompare(b));
|
||
|
||
if (!values.length) {
|
||
emptyEl.classList.remove("hidden");
|
||
listEl.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
emptyEl.classList.add("hidden");
|
||
listEl.classList.remove("hidden");
|
||
|
||
values.forEach((npub) => {
|
||
const item = document.createElement("li");
|
||
item.className =
|
||
"flex flex-col gap-2 rounded-lg bg-gray-800 px-4 py-3 sm:flex-row sm:items-center sm:justify-between";
|
||
|
||
const label = document.createElement("p");
|
||
label.className = "text-sm font-medium text-gray-100 break-all";
|
||
label.textContent = npub;
|
||
item.appendChild(label);
|
||
|
||
if (removable && typeof onRemove === "function") {
|
||
const removeBtn = document.createElement("button");
|
||
removeBtn.type = "button";
|
||
removeBtn.className =
|
||
"self-start rounded-md bg-gray-700 px-3 py-1 text-xs font-medium text-gray-100 transition hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
|
||
removeBtn.textContent = removeLabel;
|
||
removeBtn.addEventListener("click", () => {
|
||
if (confirmMessage) {
|
||
const message = confirmMessage.replace("{npub}", npub);
|
||
if (!window.confirm(message)) {
|
||
return;
|
||
}
|
||
}
|
||
removeBtn.disabled = true;
|
||
removeBtn.setAttribute("aria-busy", "true");
|
||
onRemove(npub, removeBtn);
|
||
});
|
||
item.appendChild(removeBtn);
|
||
}
|
||
|
||
listEl.appendChild(item);
|
||
});
|
||
}
|
||
|
||
populateAdminLists() {
|
||
const actorNpub = this.getCurrentUserNpub();
|
||
if (!actorNpub || !accessControl.canEditAdminLists(actorNpub)) {
|
||
this.clearAdminLists();
|
||
return;
|
||
}
|
||
|
||
const isSuperAdmin = accessControl.isSuperAdmin(actorNpub);
|
||
const editors = accessControl
|
||
.getEditors()
|
||
.filter((npub) => npub && npub !== ADMIN_SUPER_NPUB);
|
||
const whitelist = accessControl.getWhitelist();
|
||
const blacklist = accessControl.getBlacklist();
|
||
|
||
this.renderAdminList(this.adminModeratorList, this.adminModeratorsEmpty, editors, {
|
||
onRemove: (npub, button) => this.handleRemoveModerator(npub, button),
|
||
removeLabel: "Remove",
|
||
confirmMessage:
|
||
"Remove moderator {npub}? They will immediately lose access to the admin panel.",
|
||
removable: isSuperAdmin,
|
||
});
|
||
|
||
this.renderAdminList(this.adminWhitelistList, this.adminWhitelistEmpty, whitelist, {
|
||
onRemove: (npub, button) =>
|
||
this.handleAdminListMutation("whitelist", "remove", npub, button),
|
||
removeLabel: "Remove",
|
||
confirmMessage: "Remove {npub} from the whitelist?",
|
||
removable: true,
|
||
});
|
||
|
||
this.renderAdminList(this.adminBlacklistList, this.adminBlacklistEmpty, blacklist, {
|
||
onRemove: (npub, button) =>
|
||
this.handleAdminListMutation("blacklist", "remove", npub, button),
|
||
removeLabel: "Unblock",
|
||
confirmMessage: "Remove {npub} from the blacklist?",
|
||
removable: true,
|
||
});
|
||
}
|
||
|
||
getCurrentUserNpub() {
|
||
if (typeof this.currentUserNpub === "string" && this.currentUserNpub) {
|
||
return this.currentUserNpub;
|
||
}
|
||
|
||
if (!this.pubkey) {
|
||
return null;
|
||
}
|
||
|
||
const encoded = this.safeEncodeNpub(this.pubkey);
|
||
if (encoded) {
|
||
this.currentUserNpub = encoded;
|
||
}
|
||
return this.currentUserNpub;
|
||
}
|
||
|
||
canCurrentUserManageBlacklist() {
|
||
const actorNpub = this.getCurrentUserNpub();
|
||
if (!actorNpub) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
return accessControl.canEditAdminLists(actorNpub);
|
||
} catch (error) {
|
||
console.warn("Unable to verify blacklist permissions:", error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
ensureAdminActor(requireSuperAdmin = false) {
|
||
const actorNpub = this.getCurrentUserNpub();
|
||
if (!actorNpub) {
|
||
this.showError("Please login with a Nostr account to manage admin settings.");
|
||
return null;
|
||
}
|
||
if (!accessControl.canEditAdminLists(actorNpub)) {
|
||
this.showError("You do not have permission to manage BitVid moderation lists.");
|
||
return null;
|
||
}
|
||
if (requireSuperAdmin && !accessControl.isSuperAdmin(actorNpub)) {
|
||
this.showError("Only the Super Admin can manage moderators or whitelist mode.");
|
||
return null;
|
||
}
|
||
return actorNpub;
|
||
}
|
||
|
||
async handleAddModerator() {
|
||
let preloadError = null;
|
||
try {
|
||
await accessControl.ensureReady();
|
||
} catch (error) {
|
||
preloadError = error;
|
||
console.error("Failed to load admin lists before adding moderator:", error);
|
||
}
|
||
|
||
if (preloadError) {
|
||
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
|
||
return;
|
||
}
|
||
|
||
const actorNpub = this.ensureAdminActor(true);
|
||
if (!actorNpub || !this.adminModeratorInput) {
|
||
return;
|
||
}
|
||
|
||
const value = this.adminModeratorInput.value.trim();
|
||
if (!value) {
|
||
this.showError("Enter an npub to add as a moderator.");
|
||
return;
|
||
}
|
||
|
||
if (this.adminAddModeratorBtn) {
|
||
this.adminAddModeratorBtn.disabled = true;
|
||
this.adminAddModeratorBtn.setAttribute("aria-busy", "true");
|
||
}
|
||
|
||
try {
|
||
const result = await accessControl.addModerator(actorNpub, value);
|
||
if (!result.ok) {
|
||
this.showError(this.describeAdminError(result.error));
|
||
return;
|
||
}
|
||
|
||
this.adminModeratorInput.value = "";
|
||
this.showSuccess("Moderator added successfully.");
|
||
await this.onAccessControlUpdated();
|
||
} finally {
|
||
if (this.adminAddModeratorBtn) {
|
||
this.adminAddModeratorBtn.disabled = false;
|
||
this.adminAddModeratorBtn.removeAttribute("aria-busy");
|
||
}
|
||
}
|
||
}
|
||
|
||
async handleRemoveModerator(npub, button) {
|
||
let preloadError = null;
|
||
try {
|
||
await accessControl.ensureReady();
|
||
} catch (error) {
|
||
preloadError = error;
|
||
console.error("Failed to load admin lists before removing moderator:", error);
|
||
}
|
||
|
||
if (preloadError) {
|
||
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
|
||
if (button instanceof HTMLElement) {
|
||
button.disabled = false;
|
||
button.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const actorNpub = this.ensureAdminActor(true);
|
||
if (!actorNpub) {
|
||
if (button instanceof HTMLElement) {
|
||
button.disabled = false;
|
||
button.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const result = await accessControl.removeModerator(actorNpub, npub);
|
||
if (!result.ok) {
|
||
this.showError(this.describeAdminError(result.error));
|
||
if (button instanceof HTMLElement) {
|
||
button.disabled = false;
|
||
button.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
this.showSuccess("Moderator removed.");
|
||
await this.onAccessControlUpdated();
|
||
}
|
||
|
||
async handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) {
|
||
let preloadError = null;
|
||
try {
|
||
await accessControl.ensureReady();
|
||
} catch (error) {
|
||
preloadError = error;
|
||
console.error("Failed to load admin lists before updating entries:", error);
|
||
}
|
||
|
||
if (preloadError) {
|
||
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
|
||
if (sourceButton instanceof HTMLElement) {
|
||
sourceButton.disabled = false;
|
||
sourceButton.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const actorNpub = this.ensureAdminActor(false);
|
||
if (!actorNpub) {
|
||
if (sourceButton instanceof HTMLElement) {
|
||
sourceButton.disabled = false;
|
||
sourceButton.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const isWhitelist = listType === "whitelist";
|
||
const input = isWhitelist ? this.adminWhitelistInput : this.adminBlacklistInput;
|
||
const addButton = isWhitelist ? this.adminAddWhitelistBtn : this.adminAddBlacklistBtn;
|
||
const isAdd = action === "add";
|
||
|
||
let target = typeof explicitNpub === "string" ? explicitNpub.trim() : "";
|
||
if (!target && input instanceof HTMLInputElement) {
|
||
target = input.value.trim();
|
||
}
|
||
|
||
if (isAdd && !target) {
|
||
this.showError("Enter an npub before adding it to the list.");
|
||
if (sourceButton instanceof HTMLElement) {
|
||
sourceButton.disabled = false;
|
||
sourceButton.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const buttonToToggle = sourceButton || (isAdd ? addButton : null);
|
||
if (buttonToToggle instanceof HTMLElement) {
|
||
buttonToToggle.disabled = true;
|
||
buttonToToggle.setAttribute("aria-busy", "true");
|
||
}
|
||
|
||
let result;
|
||
if (isWhitelist) {
|
||
result = isAdd
|
||
? await accessControl.addToWhitelist(actorNpub, target)
|
||
: await accessControl.removeFromWhitelist(actorNpub, target);
|
||
} else {
|
||
result = isAdd
|
||
? await accessControl.addToBlacklist(actorNpub, target)
|
||
: await accessControl.removeFromBlacklist(actorNpub, target);
|
||
}
|
||
|
||
if (!result.ok) {
|
||
this.showError(this.describeAdminError(result.error));
|
||
if (buttonToToggle instanceof HTMLElement) {
|
||
buttonToToggle.disabled = false;
|
||
buttonToToggle.removeAttribute("aria-busy");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isAdd && input instanceof HTMLInputElement) {
|
||
input.value = "";
|
||
}
|
||
|
||
const successMessage = isWhitelist
|
||
? isAdd
|
||
? "Added to the whitelist."
|
||
: "Removed from the whitelist."
|
||
: isAdd
|
||
? "Added to the blacklist."
|
||
: "Removed from the blacklist.";
|
||
this.showSuccess(successMessage);
|
||
await this.onAccessControlUpdated();
|
||
|
||
if (buttonToToggle instanceof HTMLElement) {
|
||
buttonToToggle.disabled = false;
|
||
buttonToToggle.removeAttribute("aria-busy");
|
||
}
|
||
|
||
if (isAdd) {
|
||
try {
|
||
const notifyResult = await this.sendAdminListNotification({
|
||
listType,
|
||
actorNpub,
|
||
targetNpub: target,
|
||
});
|
||
if (!notifyResult?.ok) {
|
||
const errorMessage = this.describeNotificationError(
|
||
notifyResult?.error
|
||
);
|
||
if (errorMessage) {
|
||
this.showError(errorMessage);
|
||
}
|
||
if (isDevMode && notifyResult?.error) {
|
||
console.warn(
|
||
"[admin] Failed to send list notification DM:",
|
||
notifyResult
|
||
);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to send list notification DM:", error);
|
||
if (isDevMode) {
|
||
console.warn(
|
||
"List update succeeded, but DM notification threw an unexpected error.",
|
||
error
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
describeAdminError(code) {
|
||
switch (code) {
|
||
case "invalid npub":
|
||
return "Please provide a valid npub address.";
|
||
case "immutable":
|
||
return "That account cannot be modified.";
|
||
case "self":
|
||
return "You cannot blacklist yourself.";
|
||
case "forbidden":
|
||
return "You do not have permission to perform that action.";
|
||
case "nostr-unavailable":
|
||
return "Unable to reach the configured Nostr relays. Please retry once your connection is restored.";
|
||
case "nostr-extension-missing":
|
||
return "Connect a Nostr extension before editing moderation lists.";
|
||
case "signature-failed":
|
||
return "We couldn’t sign the update with your Nostr key. Please reconnect your extension and try again.";
|
||
case "publish-failed":
|
||
return "Failed to publish the update to Nostr relays. Please try again.";
|
||
case "storage-error":
|
||
return "Unable to update moderation settings. Please try again.";
|
||
default:
|
||
return "Unable to update moderation settings. Please try again.";
|
||
}
|
||
}
|
||
|
||
describeNotificationError(code) {
|
||
switch (code) {
|
||
case "nostr-extension-missing":
|
||
return "List updated, but the DM notification failed because no Nostr extension is connected.";
|
||
case "nostr-uninitialized":
|
||
return "List updated, but the DM notification system is still connecting to Nostr relays. Please try again in a moment.";
|
||
case "nip04-unavailable":
|
||
return "List updated, but your Nostr extension does not support NIP-04 encryption, so the DM notification was not sent.";
|
||
case "sign-event-unavailable":
|
||
return "List updated, but your Nostr extension could not sign the DM notification.";
|
||
case "missing-actor-pubkey":
|
||
return "List updated, but we could not determine your public key to send the DM notification.";
|
||
case "publish-failed":
|
||
return "List updated, but the DM notification could not be delivered to any relay.";
|
||
case "encryption-failed":
|
||
case "signature-failed":
|
||
return "List updated, but the DM notification failed while preparing the encrypted message.";
|
||
case "invalid-target":
|
||
case "empty-message":
|
||
return "";
|
||
default:
|
||
return "List updated, but the DM notification could not be sent.";
|
||
}
|
||
}
|
||
|
||
async sendAdminListNotification({ listType, actorNpub, targetNpub }) {
|
||
const normalizedTarget = normalizeNpub(targetNpub);
|
||
if (!normalizedTarget) {
|
||
return { ok: false, error: "invalid-target" };
|
||
}
|
||
|
||
if (!this.pubkey) {
|
||
return { ok: false, error: "missing-actor-pubkey" };
|
||
}
|
||
|
||
const actorHex = this.pubkey;
|
||
const fallbackActor = this.safeEncodeNpub(actorHex) || "a BitVid moderator";
|
||
const actorDisplay = normalizeNpub(actorNpub) || fallbackActor;
|
||
const isWhitelist = listType === "whitelist";
|
||
|
||
const introLine = isWhitelist
|
||
? `Great news—your npub ${normalizedTarget} has been added to the BitVid whitelist by ${actorDisplay}.`
|
||
: `We wanted to let you know that your npub ${normalizedTarget} has been placed on the BitVid blacklist by ${actorDisplay}.`;
|
||
|
||
const statusLine = isWhitelist
|
||
? `You now have full creator access across BitVid (${BITVID_WEBSITE_URL}).`
|
||
: `This hides your channel and prevents uploads across BitVid (${BITVID_WEBSITE_URL}) for now.`;
|
||
|
||
const followUpLine = isWhitelist
|
||
? "Please take a moment to review our community guidelines (https://bitvid.network/#view=community-guidelines), and reply to this DM if you have any questions."
|
||
: "Please review our community guidelines (https://bitvid.network/#view=community-guidelines). If you believe this was a mistake, you can submit an appeal at https://bitvid.network/?modal=appeals to request reinstatement, or reply to this DM with any questions.";
|
||
|
||
const messageBody = [
|
||
"Hi there,",
|
||
"",
|
||
introLine,
|
||
"",
|
||
statusLine,
|
||
"",
|
||
followUpLine,
|
||
"",
|
||
"— The BitVid Team",
|
||
].join("\n");
|
||
|
||
const message = `\n\n${messageBody}`;
|
||
|
||
return nostrClient.sendDirectMessage(
|
||
normalizedTarget,
|
||
message,
|
||
actorHex
|
||
);
|
||
}
|
||
|
||
async onAccessControlUpdated() {
|
||
try {
|
||
await this.refreshAdminPaneState();
|
||
} catch (error) {
|
||
console.error("Failed to refresh admin pane after update:", error);
|
||
}
|
||
|
||
this.loadVideos(true).catch((error) => {
|
||
console.error("Failed to refresh videos after admin update:", error);
|
||
});
|
||
window.dispatchEvent(new CustomEvent("bitvid:access-control-updated"));
|
||
}
|
||
|
||
/**
|
||
* Setup general event listeners for logout, modals, etc.
|
||
*/
|
||
setupEventListeners() {
|
||
// 1) Logout button
|
||
if (this.logoutButton) {
|
||
this.logoutButton.addEventListener("click", () => {
|
||
this.logout();
|
||
});
|
||
}
|
||
|
||
// 2) Profile button
|
||
if (this.profileButton) {
|
||
this.profileButton.addEventListener("click", () => {
|
||
this.openProfileModal().catch((error) => {
|
||
console.error("Failed to open profile modal:", error);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 3) Upload button => show upload modal
|
||
if (this.uploadButton) {
|
||
this.uploadButton.addEventListener("click", () => {
|
||
if (this.uploadModal) {
|
||
this.uploadModal.classList.remove("hidden");
|
||
}
|
||
});
|
||
}
|
||
|
||
// 4) Login button => show the login modal
|
||
if (this.loginButton) {
|
||
this.loginButton.addEventListener("click", () => {
|
||
console.log("Login button clicked!");
|
||
const loginModal = document.getElementById("loginModal");
|
||
if (loginModal) {
|
||
loginModal.classList.remove("hidden");
|
||
}
|
||
});
|
||
}
|
||
|
||
// 5) Close login modal button => hide modal
|
||
if (this.closeLoginModalBtn) {
|
||
this.closeLoginModalBtn.addEventListener("click", () => {
|
||
console.log("[app.js] closeLoginModal button clicked!");
|
||
const loginModal = document.getElementById("loginModal");
|
||
if (loginModal) {
|
||
loginModal.classList.add("hidden");
|
||
}
|
||
});
|
||
}
|
||
|
||
// 6) NIP-07 button inside the login modal => call the extension & login
|
||
const nip07Button = document.getElementById("loginNIP07");
|
||
if (nip07Button) {
|
||
const originalLabel = nip07Button.textContent;
|
||
const setLoadingState = (isLoading) => {
|
||
nip07Button.disabled = isLoading;
|
||
nip07Button.dataset.loading = isLoading ? "true" : "false";
|
||
nip07Button.setAttribute("aria-busy", isLoading ? "true" : "false");
|
||
nip07Button.textContent = isLoading
|
||
? "Connecting to NIP-07 extension..."
|
||
: originalLabel;
|
||
};
|
||
|
||
nip07Button.addEventListener("click", async () => {
|
||
if (nip07Button.dataset.loading === "true") {
|
||
return;
|
||
}
|
||
|
||
setLoadingState(true);
|
||
console.log(
|
||
"[app.js] loginNIP07 clicked! Attempting extension login..."
|
||
);
|
||
try {
|
||
const pubkey = await nostrClient.login(); // call the extension
|
||
console.log("[NIP-07] login returned pubkey:", pubkey);
|
||
await this.login(pubkey, { persistActive: true });
|
||
|
||
// Hide the login modal
|
||
const loginModal = document.getElementById("loginModal");
|
||
if (loginModal) {
|
||
loginModal.classList.add("hidden");
|
||
}
|
||
} catch (err) {
|
||
console.error("[NIP-07 login error]", err);
|
||
const message =
|
||
err && typeof err.message === "string" && err.message.trim()
|
||
? err.message.trim()
|
||
: "Failed to login with NIP-07. Please try again.";
|
||
this.showError(message);
|
||
} finally {
|
||
setLoadingState(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 7) Cleanup on page unload
|
||
window.addEventListener("beforeunload", () => {
|
||
this.cleanup().catch((err) => {
|
||
console.error("Cleanup before unload failed:", err);
|
||
});
|
||
});
|
||
|
||
// 8) Handle back/forward navigation => hide video modal
|
||
window.addEventListener("popstate", async () => {
|
||
console.log("[popstate] user navigated back/forward; cleaning modal...");
|
||
await this.hideModal();
|
||
});
|
||
|
||
// 9) Event delegation for the “Application Form” button inside the login modal
|
||
document.addEventListener("click", (event) => {
|
||
if (event.target && event.target.id === "openApplicationModal") {
|
||
// Hide the login modal
|
||
const loginModal = document.getElementById("loginModal");
|
||
if (loginModal) {
|
||
loginModal.classList.add("hidden");
|
||
}
|
||
// Show the application modal
|
||
const appModal = document.getElementById("nostrFormModal");
|
||
if (appModal) {
|
||
appModal.classList.remove("hidden");
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
attachVideoListHandler() {
|
||
const nextTarget = document;
|
||
|
||
if (this._videoListElement === nextTarget && this._videoListClickHandler) {
|
||
return;
|
||
}
|
||
|
||
if (this._videoListElement && this._videoListClickHandler) {
|
||
this._videoListElement.removeEventListener(
|
||
"click",
|
||
this._videoListClickHandler
|
||
);
|
||
this._videoListElement = null;
|
||
this._videoListClickHandler = null;
|
||
}
|
||
|
||
const handler = async (event) => {
|
||
const trigger = event.target.closest(
|
||
"[data-play-magnet],[data-play-url]"
|
||
);
|
||
if (!trigger) {
|
||
return;
|
||
}
|
||
|
||
const isPrimaryClick =
|
||
typeof event.button !== "number" || event.button === 0;
|
||
if (isPrimaryClick && !event.ctrlKey && !event.metaKey) {
|
||
event.preventDefault();
|
||
|
||
const rawUrlValue =
|
||
(trigger.dataset && typeof trigger.dataset.playUrl === "string"
|
||
? trigger.dataset.playUrl
|
||
: null) ?? trigger.getAttribute("data-play-url") ?? "";
|
||
const rawMagnetValue =
|
||
(trigger.dataset && typeof trigger.dataset.playMagnet === "string"
|
||
? trigger.dataset.playMagnet
|
||
: null) ?? trigger.getAttribute("data-play-magnet") ?? "";
|
||
|
||
let url = "";
|
||
if (rawUrlValue) {
|
||
try {
|
||
url = decodeURIComponent(rawUrlValue);
|
||
} catch (err) {
|
||
console.warn("Failed to decode data-play-url attribute:", err);
|
||
url = rawUrlValue;
|
||
}
|
||
}
|
||
|
||
const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : "";
|
||
const eventId = trigger.getAttribute("data-video-id");
|
||
|
||
if (eventId) {
|
||
await this.playVideoByEventId(eventId);
|
||
} else {
|
||
await this.playVideoWithFallback({ url, magnet });
|
||
}
|
||
}
|
||
};
|
||
|
||
nextTarget.addEventListener("click", handler);
|
||
this._videoListElement = nextTarget;
|
||
this._videoListClickHandler = handler;
|
||
}
|
||
|
||
/**
|
||
* Attempt to load the user's own profile from Nostr (kind:0).
|
||
*/
|
||
async loadOwnProfile(pubkey) {
|
||
try {
|
||
const events = await nostrClient.pool.list(nostrClient.relays, [
|
||
{ kinds: [0], authors: [pubkey], limit: 1 },
|
||
]);
|
||
let displayName = "User";
|
||
let picture = "assets/svg/default-profile.svg";
|
||
|
||
if (events.length && events[0].content) {
|
||
const data = JSON.parse(events[0].content);
|
||
displayName = data.name || data.display_name || "User";
|
||
picture = data.picture || "assets/svg/default-profile.svg";
|
||
}
|
||
|
||
this.setProfileCacheEntry(pubkey, {
|
||
name: displayName,
|
||
picture,
|
||
});
|
||
|
||
// If you have a top-bar avatar (profileAvatar)
|
||
if (this.profileAvatar) {
|
||
this.profileAvatar.src = picture;
|
||
}
|
||
// If you want to show name somewhere
|
||
if (this.profileModalName) {
|
||
this.profileModalName.textContent = displayName;
|
||
}
|
||
if (this.profileModalAvatar) {
|
||
this.profileModalAvatar.src = picture;
|
||
}
|
||
if (this.profileModalNpub) {
|
||
const encoded = this.safeEncodeNpub(pubkey);
|
||
this.profileModalNpub.textContent = encoded
|
||
? truncateMiddle(encoded, 48)
|
||
: "Not signed in";
|
||
}
|
||
if (this.profileChannelLink) {
|
||
const targetNpub = this.safeEncodeNpub(pubkey);
|
||
if (targetNpub) {
|
||
this.profileChannelLink.href = `#view=channel-profile&npub=${targetNpub}`;
|
||
this.profileChannelLink.dataset.targetNpub = targetNpub;
|
||
this.profileChannelLink.classList.remove("hidden");
|
||
} else {
|
||
this.profileChannelLink.removeAttribute("href");
|
||
delete this.profileChannelLink.dataset.targetNpub;
|
||
this.profileChannelLink.classList.add("hidden");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("loadOwnProfile error:", error);
|
||
}
|
||
}
|
||
|
||
async fetchAndRenderProfile(pubkey, forceRefresh = false) {
|
||
const cacheEntry = this.getProfileCacheEntry(pubkey);
|
||
if (cacheEntry) {
|
||
this.updateProfileInDOM(pubkey, cacheEntry.profile);
|
||
if (!forceRefresh) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 2) Otherwise, fetch from Nostr
|
||
try {
|
||
const userEvents = await nostrClient.pool.list(nostrClient.relays, [
|
||
{ kinds: [0], authors: [pubkey], limit: 1 },
|
||
]);
|
||
if (userEvents.length > 0 && userEvents[0].content) {
|
||
const data = JSON.parse(userEvents[0].content);
|
||
const profile = {
|
||
name: data.name || data.display_name || "Unknown",
|
||
picture: data.picture || "assets/svg/default-profile.svg",
|
||
};
|
||
|
||
// Cache it
|
||
this.setProfileCacheEntry(pubkey, profile);
|
||
// Update DOM
|
||
this.updateProfileInDOM(pubkey, profile);
|
||
}
|
||
} catch (err) {
|
||
console.error("Profile fetch error:", err);
|
||
}
|
||
}
|
||
|
||
async batchFetchProfiles(authorSet) {
|
||
const pubkeys = Array.from(authorSet);
|
||
if (!pubkeys.length) return;
|
||
|
||
const toFetch = [];
|
||
|
||
pubkeys.forEach((pubkey) => {
|
||
const cacheEntry = this.getProfileCacheEntry(pubkey);
|
||
if (cacheEntry) {
|
||
this.updateProfileInDOM(pubkey, cacheEntry.profile);
|
||
} else {
|
||
toFetch.push(pubkey);
|
||
}
|
||
});
|
||
|
||
if (!toFetch.length) {
|
||
return;
|
||
}
|
||
|
||
const filter = {
|
||
kinds: [0],
|
||
authors: toFetch,
|
||
limit: toFetch.length,
|
||
};
|
||
|
||
try {
|
||
// Query each relay
|
||
const results = await Promise.all(
|
||
nostrClient.relays.map((relayUrl) =>
|
||
nostrClient.pool.list([relayUrl], [filter])
|
||
)
|
||
);
|
||
const allProfileEvents = results.flat();
|
||
|
||
// Keep only the newest per author
|
||
const newestEvents = new Map();
|
||
for (const evt of allProfileEvents) {
|
||
if (
|
||
!newestEvents.has(evt.pubkey) ||
|
||
evt.created_at > newestEvents.get(evt.pubkey).created_at
|
||
) {
|
||
newestEvents.set(evt.pubkey, evt);
|
||
}
|
||
}
|
||
|
||
// Update the cache & DOM
|
||
for (const [pubkey, evt] of newestEvents.entries()) {
|
||
try {
|
||
const data = JSON.parse(evt.content);
|
||
const profile = {
|
||
name: data.name || data.display_name || "Unknown",
|
||
picture: data.picture || "assets/svg/default-profile.svg",
|
||
};
|
||
this.setProfileCacheEntry(pubkey, profile);
|
||
this.updateProfileInDOM(pubkey, profile);
|
||
} catch (err) {
|
||
console.error("Profile parse error:", err);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Batch profile fetch error:", err);
|
||
}
|
||
}
|
||
|
||
updateProfileInDOM(pubkey, profile) {
|
||
// For any .author-pic[data-pubkey=...]
|
||
const picEls = document.querySelectorAll(
|
||
`.author-pic[data-pubkey="${pubkey}"]`
|
||
);
|
||
picEls.forEach((el) => {
|
||
el.src = profile.picture;
|
||
});
|
||
// For any .author-name[data-pubkey=...]
|
||
const nameEls = document.querySelectorAll(
|
||
`.author-name[data-pubkey="${pubkey}"]`
|
||
);
|
||
nameEls.forEach((el) => {
|
||
el.textContent = profile.name;
|
||
});
|
||
}
|
||
|
||
async publishVideoNote(payload, { onSuccess, suppressModalClose } = {}) {
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to post a video.");
|
||
return false;
|
||
}
|
||
|
||
const title = (payload?.title || "").trim();
|
||
const url = (payload?.url || "").trim();
|
||
const magnet = (payload?.magnet || "").trim();
|
||
const thumbnail = (payload?.thumbnail || "").trim();
|
||
const description = (payload?.description || "").trim();
|
||
const ws = (payload?.ws || "").trim();
|
||
const xs = (payload?.xs || "").trim();
|
||
const enableComments =
|
||
payload?.enableComments === false
|
||
? false
|
||
: payload?.enableComments === true
|
||
? true
|
||
: true;
|
||
|
||
const formData = {
|
||
version: 3,
|
||
title,
|
||
url,
|
||
magnet,
|
||
thumbnail,
|
||
description,
|
||
mode: isDevMode ? "dev" : "live",
|
||
enableComments,
|
||
};
|
||
|
||
if (!formData.title || (!formData.url && !formData.magnet)) {
|
||
this.showError("Title and at least one of URL or Magnet is required.");
|
||
return false;
|
||
}
|
||
|
||
if (formData.url && !/^https:\/\//i.test(formData.url)) {
|
||
this.showError("Hosted video URLs must use HTTPS.");
|
||
return false;
|
||
}
|
||
|
||
if (formData.magnet) {
|
||
const normalizedMagnet = normalizeAndAugmentMagnet(formData.magnet, {
|
||
ws,
|
||
xs,
|
||
});
|
||
formData.magnet = normalizedMagnet;
|
||
const hints = extractMagnetHints(normalizedMagnet);
|
||
formData.ws = hints.ws;
|
||
formData.xs = hints.xs;
|
||
} else {
|
||
formData.ws = "";
|
||
formData.xs = "";
|
||
}
|
||
|
||
try {
|
||
await nostrClient.publishVideo(formData, this.pubkey);
|
||
if (typeof onSuccess === "function") {
|
||
await onSuccess();
|
||
}
|
||
if (suppressModalClose !== true && this.uploadModal) {
|
||
this.uploadModal.classList.add("hidden");
|
||
}
|
||
await this.loadVideos();
|
||
this.showSuccess("Video shared successfully!");
|
||
return true;
|
||
} catch (err) {
|
||
console.error("Failed to publish video:", err);
|
||
this.showError("Failed to share video. Please try again later.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async handleEditVideoSubmit() {
|
||
if (!this.activeEditVideo || !this.editVideoModal) {
|
||
this.showError("No video selected for editing.");
|
||
return;
|
||
}
|
||
|
||
const fieldValue = (id) => {
|
||
const el = this.editVideoModal.querySelector(`#${id}`);
|
||
if (!el || typeof el.value !== "string") {
|
||
return "";
|
||
}
|
||
return el.value.trim();
|
||
};
|
||
|
||
const original = this.activeEditVideo;
|
||
|
||
const titleInput = this.editVideoModal.querySelector("#editVideoTitle");
|
||
const urlInput = this.editVideoModal.querySelector("#editVideoUrl");
|
||
const magnetInput = this.editVideoModal.querySelector("#editVideoMagnet");
|
||
const wsInput = this.editVideoModal.querySelector("#editVideoWs");
|
||
const xsInput = this.editVideoModal.querySelector("#editVideoXs");
|
||
const thumbnailInput = this.editVideoModal.querySelector(
|
||
"#editVideoThumbnail"
|
||
);
|
||
const descriptionInput = this.editVideoModal.querySelector(
|
||
"#editVideoDescription"
|
||
);
|
||
|
||
const newTitle = fieldValue("editVideoTitle");
|
||
const newUrl = fieldValue("editVideoUrl");
|
||
const newMagnet = fieldValue("editVideoMagnet");
|
||
const newWs = fieldValue("editVideoWs");
|
||
const newXs = fieldValue("editVideoXs");
|
||
const newThumbnail = fieldValue("editVideoThumbnail");
|
||
const newDescription = fieldValue("editVideoDescription");
|
||
const commentsEl = this.editVideoModal.querySelector(
|
||
"#editEnableComments"
|
||
);
|
||
|
||
const isEditing = (input) => !input || input.readOnly === false;
|
||
|
||
const titleWasEdited = isEditing(titleInput);
|
||
const urlWasEdited = isEditing(urlInput);
|
||
const magnetWasEdited = isEditing(magnetInput);
|
||
|
||
const finalTitle = titleWasEdited ? newTitle : original.title || "";
|
||
const finalUrl = urlWasEdited ? newUrl : original.url || "";
|
||
const shouldUseOriginalWs = wsInput ? wsInput.readOnly !== false : true;
|
||
const shouldUseOriginalXs = xsInput ? xsInput.readOnly !== false : true;
|
||
let finalWs = shouldUseOriginalWs ? original.ws || "" : newWs;
|
||
let finalXs = shouldUseOriginalXs ? original.xs || "" : newXs;
|
||
let finalMagnet = magnetWasEdited ? newMagnet : original.magnet || "";
|
||
const finalThumbnail = isEditing(thumbnailInput)
|
||
? newThumbnail
|
||
: original.thumbnail || "";
|
||
const finalDescription = isEditing(descriptionInput)
|
||
? newDescription
|
||
: original.description || "";
|
||
const originalEnableComments =
|
||
typeof original.enableComments === "boolean"
|
||
? original.enableComments
|
||
: true;
|
||
|
||
let finalEnableComments = originalEnableComments;
|
||
if (commentsEl) {
|
||
if (commentsEl.disabled) {
|
||
finalEnableComments = commentsEl.dataset.originalValue === "true";
|
||
} else {
|
||
finalEnableComments = commentsEl.checked;
|
||
}
|
||
}
|
||
|
||
if (!finalTitle || (!finalUrl && !finalMagnet)) {
|
||
this.showError("Title and at least one of URL or Magnet is required.");
|
||
return;
|
||
}
|
||
|
||
if (finalUrl && !/^https:\/\//i.test(finalUrl)) {
|
||
this.showError("Hosted video URLs must use HTTPS.");
|
||
return;
|
||
}
|
||
|
||
if (finalMagnet) {
|
||
const normalizedMagnet = normalizeAndAugmentMagnet(finalMagnet, {
|
||
ws: finalWs,
|
||
xs: finalXs,
|
||
});
|
||
finalMagnet = normalizedMagnet;
|
||
const hints = extractMagnetHints(normalizedMagnet);
|
||
finalWs = hints.ws;
|
||
finalXs = hints.xs;
|
||
} else {
|
||
finalWs = "";
|
||
finalXs = "";
|
||
}
|
||
|
||
const updatedData = {
|
||
version: original.version || 2,
|
||
title: finalTitle,
|
||
magnet: finalMagnet,
|
||
url: finalUrl,
|
||
thumbnail: finalThumbnail,
|
||
description: finalDescription,
|
||
mode: isDevMode ? "dev" : "live",
|
||
ws: finalWs,
|
||
xs: finalXs,
|
||
wsEdited: !shouldUseOriginalWs,
|
||
xsEdited: !shouldUseOriginalXs,
|
||
urlEdited: urlWasEdited,
|
||
magnetEdited: magnetWasEdited,
|
||
enableComments: finalEnableComments,
|
||
};
|
||
|
||
const originalEvent = {
|
||
id: original.id,
|
||
pubkey: original.pubkey,
|
||
videoRootId: original.videoRootId,
|
||
};
|
||
|
||
try {
|
||
await nostrClient.editVideo(originalEvent, updatedData, this.pubkey);
|
||
await this.loadVideos();
|
||
this.videosMap.clear();
|
||
this.showSuccess("Video updated successfully!");
|
||
this.hideEditVideoModal();
|
||
this.forceRefreshAllProfiles();
|
||
} catch (err) {
|
||
console.error("Failed to edit video:", err);
|
||
this.showError("Failed to edit video. Please try again.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Actually handle the upload form submission.
|
||
*/
|
||
async handleUploadSubmit() {
|
||
const titleEl = document.getElementById("uploadTitle");
|
||
const urlEl = document.getElementById("uploadUrl");
|
||
const magnetEl = document.getElementById("uploadMagnet");
|
||
const wsEl = document.getElementById("uploadWs");
|
||
const xsEl = document.getElementById("uploadXs");
|
||
const thumbEl = document.getElementById("uploadThumbnail");
|
||
const descEl = document.getElementById("uploadDescription");
|
||
const privEl = document.getElementById("uploadIsPrivate");
|
||
const commentsEl = document.getElementById("uploadEnableComments");
|
||
|
||
const title = titleEl?.value.trim() || "";
|
||
const url = urlEl?.value.trim() || "";
|
||
const magnet = magnetEl?.value.trim() || "";
|
||
const ws = wsEl?.value.trim() || "";
|
||
const xs = xsEl?.value.trim() || "";
|
||
const thumbnail = thumbEl?.value.trim() || "";
|
||
const description = descEl?.value.trim() || "";
|
||
const enableComments = commentsEl ? commentsEl.checked : true;
|
||
|
||
const payload = {
|
||
title,
|
||
url,
|
||
magnet,
|
||
thumbnail,
|
||
description,
|
||
ws,
|
||
xs,
|
||
enableComments,
|
||
};
|
||
|
||
await this.publishVideoNote(payload, {
|
||
onSuccess: () => {
|
||
if (titleEl) titleEl.value = "";
|
||
if (urlEl) urlEl.value = "";
|
||
if (magnetEl) magnetEl.value = "";
|
||
if (wsEl) wsEl.value = "";
|
||
if (xsEl) xsEl.value = "";
|
||
if (thumbEl) thumbEl.value = "";
|
||
if (descEl) descEl.value = "";
|
||
if (privEl) privEl.checked = false;
|
||
if (commentsEl) commentsEl.checked = true;
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Removes a saved profile entry and clears the active pointer when it matches
|
||
* the removed pubkey. Intended for the profile-switcher UI to prune old
|
||
* accounts without touching cached avatars.
|
||
*/
|
||
removeSavedProfile(pubkey) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
const target = normalized || (typeof pubkey === "string" ? pubkey.trim() : "");
|
||
if (!target) {
|
||
return;
|
||
}
|
||
|
||
const nextProfiles = this.savedProfiles.filter(
|
||
(entry) => entry.pubkey !== target
|
||
);
|
||
if (nextProfiles.length === this.savedProfiles.length) {
|
||
return;
|
||
}
|
||
|
||
this.savedProfiles = nextProfiles;
|
||
|
||
if (this.activeProfilePubkey === target) {
|
||
this.activeProfilePubkey = null;
|
||
}
|
||
|
||
this.persistSavedProfiles();
|
||
this.renderSavedProfiles();
|
||
}
|
||
|
||
async switchProfile(pubkey) {
|
||
const normalizedTarget = this.normalizeHexPubkey(pubkey);
|
||
if (!normalizedTarget) {
|
||
this.showError("Unable to switch profiles: invalid account.");
|
||
return;
|
||
}
|
||
|
||
const normalizedActive = this.normalizeHexPubkey(this.activeProfilePubkey);
|
||
if (normalizedActive && normalizedActive === normalizedTarget) {
|
||
this.hideProfileModal();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await nostrClient.login({
|
||
allowAccountSelection: true,
|
||
expectPubkey: normalizedTarget,
|
||
});
|
||
} catch (error) {
|
||
const message =
|
||
error && typeof error.message === "string" && error.message.trim()
|
||
? error.message.trim()
|
||
: "Profile switch was cancelled.";
|
||
this.showError(message);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.login(normalizedTarget, { persistActive: true });
|
||
} catch (error) {
|
||
console.error("Failed to finalize profile switch:", error);
|
||
const message =
|
||
error && typeof error.message === "string" && error.message.trim()
|
||
? error.message.trim()
|
||
: "Failed to switch profiles. Please try again.";
|
||
this.showError(message);
|
||
return;
|
||
}
|
||
|
||
this.profileSwitcherSelectionPubkey = null;
|
||
|
||
let reordered = false;
|
||
const currentIndex = this.savedProfiles.findIndex(
|
||
(entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedTarget
|
||
);
|
||
if (currentIndex > 0) {
|
||
const [moved] = this.savedProfiles.splice(currentIndex, 1);
|
||
this.savedProfiles.unshift(moved);
|
||
reordered = true;
|
||
}
|
||
|
||
if (reordered) {
|
||
this.persistSavedProfiles({ persistActive: true });
|
||
}
|
||
|
||
this.renderSavedProfiles();
|
||
this.hideProfileModal();
|
||
}
|
||
|
||
async applyPostLoginState() {
|
||
await this.refreshAdminPaneState();
|
||
|
||
try {
|
||
await userBlocks.loadBlocks(this.pubkey);
|
||
} catch (error) {
|
||
console.warn("Failed to load personal block list:", error);
|
||
}
|
||
|
||
this.populateBlockedList();
|
||
|
||
if (this.loginButton) {
|
||
this.loginButton.classList.add("hidden");
|
||
this.loginButton.setAttribute("hidden", "");
|
||
this.loginButton.style.display = "none";
|
||
}
|
||
|
||
if (this.logoutButton) {
|
||
this.logoutButton.classList.remove("hidden");
|
||
}
|
||
|
||
if (this.userStatus) {
|
||
this.userStatus.classList.add("hidden");
|
||
}
|
||
|
||
if (this.uploadButton) {
|
||
this.uploadButton.classList.remove("hidden");
|
||
this.uploadButton.removeAttribute("hidden");
|
||
this.uploadButton.style.display = "inline-flex";
|
||
}
|
||
|
||
if (this.profileButton) {
|
||
this.profileButton.classList.remove("hidden");
|
||
this.profileButton.removeAttribute("hidden");
|
||
this.profileButton.style.display = "inline-flex";
|
||
}
|
||
|
||
if (this.subscriptionsLink) {
|
||
this.subscriptionsLink.classList.remove("hidden");
|
||
}
|
||
|
||
if (this.pubkey) {
|
||
this.loadOwnProfile(this.pubkey);
|
||
}
|
||
|
||
await this.loadVideos();
|
||
|
||
this.forceRefreshAllProfiles();
|
||
|
||
await this.updateCloudflareBucketPreview();
|
||
}
|
||
|
||
/**
|
||
* Called upon successful login.
|
||
*/
|
||
async login(pubkey, options = {}) {
|
||
console.log("[app.js] login() called with pubkey =", pubkey);
|
||
|
||
let normalizedOptions = options;
|
||
if (typeof normalizedOptions === "boolean") {
|
||
normalizedOptions = { persistActive: normalizedOptions };
|
||
} else if (!normalizedOptions || typeof normalizedOptions !== "object") {
|
||
normalizedOptions = {};
|
||
}
|
||
|
||
const { persistActive = true } = normalizedOptions;
|
||
|
||
const previousActivePubkey =
|
||
this.normalizeHexPubkey(this.pubkey) ||
|
||
(typeof this.pubkey === "string" ? this.pubkey.trim() : "");
|
||
|
||
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
|
||
const nextActivePubkey =
|
||
normalizedPubkey || (typeof pubkey === "string" ? pubkey.trim() : "");
|
||
|
||
const identityChanged = previousActivePubkey !== nextActivePubkey;
|
||
if (identityChanged) {
|
||
this.resetViewLoggingState();
|
||
}
|
||
|
||
if (normalizedPubkey) {
|
||
this.pubkey = normalizedPubkey;
|
||
} else {
|
||
this.pubkey = pubkey;
|
||
}
|
||
this.currentUserNpub = this.safeEncodeNpub(this.pubkey);
|
||
|
||
if (
|
||
identityChanged &&
|
||
typeof window !== "undefined" &&
|
||
typeof window.dispatchEvent === "function"
|
||
) {
|
||
window.dispatchEvent(
|
||
new CustomEvent("bitvid:auth-changed", {
|
||
detail: {
|
||
pubkey: nextActivePubkey || null,
|
||
previousPubkey: previousActivePubkey || null,
|
||
},
|
||
})
|
||
);
|
||
}
|
||
|
||
let savedProfilesMutated = false;
|
||
if (normalizedPubkey) {
|
||
const cacheEntry = this.profileCache.get(normalizedPubkey);
|
||
const cachedProfile = cacheEntry?.profile || {};
|
||
const existingIndex = this.savedProfiles.findIndex(
|
||
(candidate) => candidate.pubkey === normalizedPubkey
|
||
);
|
||
const existingEntry =
|
||
existingIndex >= 0 ? this.savedProfiles[existingIndex] : null;
|
||
const npub = this.safeEncodeNpub(normalizedPubkey);
|
||
const nextEntry = {
|
||
pubkey: normalizedPubkey,
|
||
npub: npub || existingEntry?.npub || null,
|
||
name: cachedProfile.name || existingEntry?.name || "",
|
||
picture: cachedProfile.picture || existingEntry?.picture || "",
|
||
authType: "nip07",
|
||
};
|
||
|
||
if (existingIndex >= 0) {
|
||
const currentEntry = this.savedProfiles[existingIndex];
|
||
const changed =
|
||
currentEntry.npub !== nextEntry.npub ||
|
||
currentEntry.name !== nextEntry.name ||
|
||
currentEntry.picture !== nextEntry.picture ||
|
||
currentEntry.authType !== nextEntry.authType;
|
||
if (changed) {
|
||
this.savedProfiles[existingIndex] = nextEntry;
|
||
savedProfilesMutated = true;
|
||
}
|
||
} else {
|
||
this.savedProfiles.push(nextEntry);
|
||
savedProfilesMutated = true;
|
||
}
|
||
|
||
this.persistActiveProfileSelection(normalizedPubkey, {
|
||
persist: persistActive,
|
||
});
|
||
|
||
if (!persistActive && savedProfilesMutated) {
|
||
this.persistSavedProfiles({ persistActive: false });
|
||
}
|
||
} else {
|
||
if (persistActive) {
|
||
console.warn(
|
||
"[app.js] login() requested storage persistence but pubkey was invalid; skipping saved profile update."
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!normalizedPubkey && savedProfilesMutated) {
|
||
this.persistSavedProfiles({ persistActive: false });
|
||
}
|
||
|
||
await this.applyPostLoginState();
|
||
}
|
||
|
||
/**
|
||
* Logout logic
|
||
*/
|
||
async logout() {
|
||
nostrClient.logout();
|
||
const previousPubkey =
|
||
this.normalizeHexPubkey(this.pubkey) ||
|
||
(typeof this.pubkey === "string" ? this.pubkey.trim() : "");
|
||
this.resetViewLoggingState();
|
||
this.pubkey = null;
|
||
this.currentUserNpub = null;
|
||
|
||
if (
|
||
previousPubkey &&
|
||
typeof window !== "undefined" &&
|
||
typeof window.dispatchEvent === "function"
|
||
) {
|
||
window.dispatchEvent(
|
||
new CustomEvent("bitvid:auth-changed", {
|
||
detail: {
|
||
pubkey: null,
|
||
previousPubkey,
|
||
},
|
||
})
|
||
);
|
||
}
|
||
|
||
this.persistActiveProfileSelection(null, { persist: true });
|
||
|
||
userBlocks.reset();
|
||
this.populateBlockedList();
|
||
|
||
// Show the login button again
|
||
if (this.loginButton) {
|
||
this.loginButton.classList.remove("hidden");
|
||
this.loginButton.removeAttribute("hidden");
|
||
this.loginButton.style.display = "";
|
||
}
|
||
|
||
// Hide logout or userStatus
|
||
if (this.logoutButton) {
|
||
this.logoutButton.classList.add("hidden");
|
||
}
|
||
if (this.userStatus) {
|
||
this.userStatus.classList.add("hidden");
|
||
}
|
||
if (this.userPubKey) {
|
||
this.userPubKey.textContent = "";
|
||
}
|
||
|
||
// Hide upload & profile
|
||
if (this.uploadButton) {
|
||
this.uploadButton.classList.add("hidden");
|
||
this.uploadButton.setAttribute("hidden", "");
|
||
this.uploadButton.style.display = "none";
|
||
}
|
||
if (this.profileButton) {
|
||
this.profileButton.classList.add("hidden");
|
||
this.profileButton.setAttribute("hidden", "");
|
||
this.profileButton.style.display = "none";
|
||
}
|
||
if (this.profileChannelLink) {
|
||
this.profileChannelLink.classList.add("hidden");
|
||
this.profileChannelLink.removeAttribute("href");
|
||
delete this.profileChannelLink.dataset.targetNpub;
|
||
}
|
||
|
||
// Hide the Subscriptions link
|
||
if (this.subscriptionsLink) {
|
||
this.subscriptionsLink.classList.add("hidden");
|
||
}
|
||
|
||
await this.refreshAdminPaneState();
|
||
|
||
// Refresh the video list so user sees only public videos again
|
||
await this.loadVideos();
|
||
|
||
// Force a fresh fetch of all profile pictures/names (public ones in this case)
|
||
this.forceRefreshAllProfiles();
|
||
|
||
await this.updateCloudflareBucketPreview();
|
||
}
|
||
|
||
/**
|
||
* Cleanup resources on unload or modal close.
|
||
*/
|
||
async cleanup({ preserveSubscriptions = false, preserveObservers = false } = {}) {
|
||
this.log(
|
||
`[cleanup] Requested (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers})`
|
||
);
|
||
// Serialise teardown so overlapping calls (e.g. close button spam) don't
|
||
// race each other and clobber a fresh playback setup.
|
||
if (this.cleanupPromise) {
|
||
this.log("[cleanup] Waiting for in-flight cleanup to finish before starting a new run.");
|
||
try {
|
||
await this.cleanupPromise;
|
||
} catch (err) {
|
||
console.warn("Previous cleanup rejected:", err);
|
||
}
|
||
}
|
||
|
||
const runCleanup = async () => {
|
||
this.log(
|
||
`[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers})`
|
||
);
|
||
try {
|
||
this.cancelPendingViewLogging();
|
||
this.clearActiveIntervals();
|
||
this.cleanupUrlPlaybackWatchdog();
|
||
this.teardownModalViewCountSubscription();
|
||
|
||
if (!preserveObservers && this.mediaLoader) {
|
||
this.mediaLoader.disconnect();
|
||
}
|
||
|
||
if (!preserveObservers) {
|
||
this.teardownAllViewCountSubscriptions();
|
||
} else {
|
||
this.pruneDetachedViewCountElements();
|
||
}
|
||
|
||
if (!preserveSubscriptions && this.videoSubscription) {
|
||
try {
|
||
if (typeof this.videoSubscription.unsub === "function") {
|
||
this.videoSubscription.unsub();
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to unsubscribe from video feed:", err);
|
||
} finally {
|
||
this.videoSubscription = null;
|
||
}
|
||
}
|
||
|
||
// If there's a small inline player
|
||
if (this.videoElement) {
|
||
this.videoElement = this.teardownVideoElement(this.videoElement);
|
||
}
|
||
// If there's a modal video
|
||
if (this.modalVideo) {
|
||
if (typeof this.modalPosterCleanup === "function") {
|
||
try {
|
||
this.modalPosterCleanup();
|
||
} catch (err) {
|
||
console.warn("[cleanup] modal poster cleanup threw:", err);
|
||
} finally {
|
||
this.modalPosterCleanup = null;
|
||
}
|
||
}
|
||
const refreshedModal = this.teardownVideoElement(this.modalVideo, {
|
||
replaceNode: true,
|
||
});
|
||
if (refreshedModal) {
|
||
this.modalVideo = refreshedModal;
|
||
}
|
||
}
|
||
// Tell webtorrent to cleanup
|
||
await torrentClient.cleanup();
|
||
this.log("[cleanup] WebTorrent cleanup resolved.");
|
||
} catch (err) {
|
||
console.error("Cleanup error:", err);
|
||
} finally {
|
||
this.log("[cleanup] Finished.");
|
||
}
|
||
};
|
||
|
||
const cleanupPromise = runCleanup();
|
||
this.cleanupPromise = cleanupPromise;
|
||
|
||
try {
|
||
await cleanupPromise;
|
||
} finally {
|
||
if (this.cleanupPromise === cleanupPromise) {
|
||
this.cleanupPromise = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
async waitForCleanup() {
|
||
if (!this.cleanupPromise) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.log("[waitForCleanup] Awaiting previous cleanup before continuing.");
|
||
await this.cleanupPromise;
|
||
this.log("[waitForCleanup] Previous cleanup completed.");
|
||
} catch (err) {
|
||
console.warn("waitForCleanup observed a rejected cleanup:", err);
|
||
}
|
||
}
|
||
|
||
clearActiveIntervals() {
|
||
if (!Array.isArray(this.activeIntervals) || !this.activeIntervals.length) {
|
||
return;
|
||
}
|
||
this.activeIntervals.forEach((id) => clearInterval(id));
|
||
this.activeIntervals = [];
|
||
}
|
||
|
||
cleanupUrlPlaybackWatchdog() {
|
||
if (typeof this.urlPlaybackWatchdogCleanup === "function") {
|
||
try {
|
||
this.urlPlaybackWatchdogCleanup();
|
||
} catch (err) {
|
||
console.warn("[cleanupUrlPlaybackWatchdog]", err);
|
||
} finally {
|
||
this.urlPlaybackWatchdogCleanup = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
cancelPendingViewLogging() {
|
||
const state = this.playbackTelemetryState;
|
||
if (!state) {
|
||
return;
|
||
}
|
||
|
||
if (state.timerId) {
|
||
clearTimeout(state.timerId);
|
||
}
|
||
|
||
if (Array.isArray(state.handlers) && state.videoEl) {
|
||
for (const { eventName, handler } of state.handlers) {
|
||
try {
|
||
state.videoEl.removeEventListener(eventName, handler);
|
||
} catch (err) {
|
||
if (isDevMode) {
|
||
console.warn(
|
||
`[cancelPendingViewLogging] Failed to detach ${eventName} listener:`,
|
||
err
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
this.playbackTelemetryState = null;
|
||
}
|
||
|
||
resetViewLoggingState() {
|
||
this.cancelPendingViewLogging();
|
||
if (this.loggedViewPointerKeys.size > 0) {
|
||
this.loggedViewPointerKeys.clear();
|
||
}
|
||
}
|
||
|
||
getActiveViewIdentityKey() {
|
||
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
|
||
if (normalizedUser) {
|
||
return `actor:${normalizedUser}`;
|
||
}
|
||
|
||
const sessionActorPubkey = this.normalizeHexPubkey(
|
||
nostrClient?.sessionActor?.pubkey
|
||
);
|
||
if (sessionActorPubkey) {
|
||
return `actor:${sessionActorPubkey}`;
|
||
}
|
||
|
||
return "actor:anonymous";
|
||
}
|
||
|
||
deriveViewIdentityKeyFromEvent(event) {
|
||
if (!event || typeof event !== "object") {
|
||
return "";
|
||
}
|
||
|
||
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
|
||
if (!normalizedPubkey) {
|
||
return "";
|
||
}
|
||
|
||
return `actor:${normalizedPubkey}`;
|
||
}
|
||
|
||
buildViewCooldownKey(pointerKey, identityKey) {
|
||
const normalizedPointerKey =
|
||
typeof pointerKey === "string" && pointerKey.trim()
|
||
? pointerKey.trim()
|
||
: "";
|
||
if (!normalizedPointerKey) {
|
||
return "";
|
||
}
|
||
|
||
const normalizedIdentity =
|
||
typeof identityKey === "string" && identityKey.trim()
|
||
? identityKey.trim().toLowerCase()
|
||
: "";
|
||
|
||
return normalizedIdentity
|
||
? `${normalizedPointerKey}::${normalizedIdentity}`
|
||
: normalizedPointerKey;
|
||
}
|
||
|
||
preparePlaybackViewLogging(videoEl) {
|
||
this.cancelPendingViewLogging();
|
||
|
||
if (!videoEl || typeof videoEl.addEventListener !== "function") {
|
||
return;
|
||
}
|
||
|
||
const pointer = this.currentVideoPointer;
|
||
const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer);
|
||
if (!pointer || !pointerKey) {
|
||
return;
|
||
}
|
||
|
||
const viewerIdentityKey = this.getActiveViewIdentityKey();
|
||
const cooldownKey = this.buildViewCooldownKey(pointerKey, viewerIdentityKey);
|
||
|
||
if (cooldownKey && this.loggedViewPointerKeys.has(cooldownKey)) {
|
||
return;
|
||
}
|
||
|
||
const THRESHOLD_SECONDS = 12;
|
||
const state = {
|
||
videoEl,
|
||
pointer,
|
||
pointerKey,
|
||
viewerIdentityKey,
|
||
handlers: [],
|
||
timerId: null,
|
||
fired: false,
|
||
};
|
||
|
||
const finalize = () => {
|
||
if (state.fired) {
|
||
return;
|
||
}
|
||
state.fired = true;
|
||
cancelTimer();
|
||
this.cancelPendingViewLogging();
|
||
|
||
const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } =
|
||
state;
|
||
|
||
(async () => {
|
||
try {
|
||
const result = await recordVideoView(thresholdPointer);
|
||
const viewOk = !!result?.view?.ok;
|
||
if (viewOk) {
|
||
const eventIdentityKey =
|
||
this.deriveViewIdentityKeyFromEvent(result?.view?.event) ||
|
||
state.viewerIdentityKey ||
|
||
this.getActiveViewIdentityKey();
|
||
const keyToPersist = this.buildViewCooldownKey(
|
||
thresholdPointerKey,
|
||
eventIdentityKey
|
||
);
|
||
if (keyToPersist) {
|
||
this.loggedViewPointerKeys.add(keyToPersist);
|
||
}
|
||
if (result?.view?.event) {
|
||
ingestLocalViewEvent({
|
||
event: result.view.event,
|
||
pointer: thresholdPointer,
|
||
});
|
||
}
|
||
} else if (isDevMode) {
|
||
console.warn(
|
||
"[playVideoWithFallback] View event rejected by relays:",
|
||
result?.view || result
|
||
);
|
||
}
|
||
|
||
let historyOk = !!result?.history?.ok;
|
||
if (!historyOk) {
|
||
if (isDevMode) {
|
||
console.warn(
|
||
"[playVideoWithFallback] Watch history update via recordVideoView failed; retrying.",
|
||
result?.history || result
|
||
);
|
||
}
|
||
const fallbackHistory = await updateWatchHistoryList(
|
||
thresholdPointer
|
||
);
|
||
historyOk = !!fallbackHistory?.ok;
|
||
if (!historyOk && isDevMode) {
|
||
console.warn(
|
||
"[playVideoWithFallback] Watch history retry failed:",
|
||
fallbackHistory
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!viewOk && isDevMode && historyOk) {
|
||
console.warn(
|
||
"[playVideoWithFallback] Watch history updated but view event was rejected."
|
||
);
|
||
}
|
||
} catch (error) {
|
||
if (isDevMode) {
|
||
console.warn(
|
||
"[playVideoWithFallback] Exception while recording video view:",
|
||
error
|
||
);
|
||
}
|
||
try {
|
||
const fallbackHistory = await updateWatchHistoryList(
|
||
thresholdPointer
|
||
);
|
||
if (!fallbackHistory?.ok && isDevMode) {
|
||
console.warn(
|
||
"[playVideoWithFallback] Watch history retry after view exception failed:",
|
||
fallbackHistory
|
||
);
|
||
}
|
||
} catch (historyError) {
|
||
if (isDevMode) {
|
||
console.warn(
|
||
"[playVideoWithFallback] Exception while retrying watch history after view failure:",
|
||
historyError
|
||
);
|
||
}
|
||
}
|
||
}
|
||
})().catch((error) => {
|
||
if (isDevMode) {
|
||
console.warn(
|
||
"[playVideoWithFallback] Unexpected error while recording video view:",
|
||
error
|
||
);
|
||
}
|
||
});
|
||
};
|
||
|
||
const cancelTimer = () => {
|
||
if (state.timerId) {
|
||
clearTimeout(state.timerId);
|
||
state.timerId = null;
|
||
}
|
||
};
|
||
|
||
const scheduleTimer = () => {
|
||
if (state.fired) {
|
||
return;
|
||
}
|
||
const currentSeconds = Number.isFinite(videoEl.currentTime)
|
||
? videoEl.currentTime
|
||
: 0;
|
||
const remainingMs = Math.max(
|
||
0,
|
||
Math.ceil((THRESHOLD_SECONDS - currentSeconds) * 1000)
|
||
);
|
||
if (remainingMs <= 0) {
|
||
finalize();
|
||
return;
|
||
}
|
||
state.timerId = window.setTimeout(finalize, remainingMs);
|
||
};
|
||
|
||
const registerHandler = (eventName, handler) => {
|
||
videoEl.addEventListener(eventName, handler);
|
||
state.handlers.push({ eventName, handler });
|
||
};
|
||
|
||
registerHandler("timeupdate", () => {
|
||
if (videoEl.currentTime >= THRESHOLD_SECONDS) {
|
||
finalize();
|
||
}
|
||
});
|
||
|
||
const cancelOnPause = () => {
|
||
if (!state.fired) {
|
||
cancelTimer();
|
||
}
|
||
};
|
||
|
||
["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) =>
|
||
registerHandler(event, cancelOnPause)
|
||
);
|
||
|
||
const resumeIfNeeded = () => {
|
||
if (!state.fired && !state.timerId) {
|
||
scheduleTimer();
|
||
}
|
||
};
|
||
|
||
["play", "playing"].forEach((event) =>
|
||
registerHandler(event, resumeIfNeeded)
|
||
);
|
||
|
||
this.playbackTelemetryState = state;
|
||
|
||
if (!videoEl.paused && videoEl.currentTime > 0) {
|
||
resumeIfNeeded();
|
||
}
|
||
}
|
||
|
||
teardownVideoElement(videoElement, { replaceNode = false } = {}) {
|
||
if (!videoElement) {
|
||
this.log(
|
||
`[teardownVideoElement] No video provided (replaceNode=${replaceNode}); skipping.`
|
||
);
|
||
return videoElement;
|
||
}
|
||
|
||
const safe = (fn) => {
|
||
try {
|
||
fn();
|
||
} catch (err) {
|
||
console.warn("[teardownVideoElement]", err);
|
||
}
|
||
};
|
||
|
||
const describeSource = () => {
|
||
try {
|
||
return videoElement.currentSrc || videoElement.src || "<unset>";
|
||
} catch (err) {
|
||
return "<unavailable>";
|
||
}
|
||
};
|
||
|
||
this.log(
|
||
`[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}`
|
||
);
|
||
|
||
safe(() => videoElement.pause());
|
||
|
||
safe(() => {
|
||
videoElement.removeAttribute("src");
|
||
videoElement.src = "";
|
||
});
|
||
|
||
safe(() => {
|
||
videoElement.srcObject = null;
|
||
});
|
||
|
||
safe(() => {
|
||
if ("crossOrigin" in videoElement) {
|
||
videoElement.crossOrigin = null;
|
||
}
|
||
if (videoElement.hasAttribute("crossorigin")) {
|
||
videoElement.removeAttribute("crossorigin");
|
||
}
|
||
});
|
||
|
||
safe(() => {
|
||
if (typeof videoElement.load === "function") {
|
||
videoElement.load();
|
||
}
|
||
});
|
||
|
||
if (!replaceNode || !videoElement.parentNode) {
|
||
this.log(
|
||
`[teardownVideoElement] Completed without node replacement (readyState=${videoElement.readyState}).`
|
||
);
|
||
return videoElement;
|
||
}
|
||
|
||
const parent = videoElement.parentNode;
|
||
const clone = videoElement.cloneNode(false);
|
||
|
||
if (clone.dataset && "autoplayBound" in clone.dataset) {
|
||
delete clone.dataset.autoplayBound;
|
||
}
|
||
if (clone.hasAttribute("data-autoplay-bound")) {
|
||
clone.removeAttribute("data-autoplay-bound");
|
||
}
|
||
|
||
safe(() => {
|
||
clone.removeAttribute("src");
|
||
clone.src = "";
|
||
});
|
||
|
||
safe(() => {
|
||
clone.srcObject = null;
|
||
});
|
||
|
||
safe(() => {
|
||
if ("crossOrigin" in clone) {
|
||
clone.crossOrigin = null;
|
||
}
|
||
if (clone.hasAttribute("crossorigin")) {
|
||
clone.removeAttribute("crossorigin");
|
||
}
|
||
});
|
||
|
||
clone.autoplay = videoElement.autoplay;
|
||
clone.controls = videoElement.controls;
|
||
clone.loop = videoElement.loop;
|
||
clone.muted = videoElement.muted;
|
||
clone.defaultMuted = videoElement.defaultMuted;
|
||
clone.preload = videoElement.preload;
|
||
clone.playsInline = videoElement.playsInline;
|
||
|
||
clone.poster = "";
|
||
if (clone.hasAttribute("poster")) {
|
||
clone.removeAttribute("poster");
|
||
}
|
||
|
||
let replaced = false;
|
||
safe(() => {
|
||
parent.replaceChild(clone, videoElement);
|
||
replaced = true;
|
||
});
|
||
|
||
if (!replaced) {
|
||
return videoElement;
|
||
}
|
||
|
||
safe(() => {
|
||
if (typeof clone.load === "function") {
|
||
clone.load();
|
||
}
|
||
});
|
||
|
||
this.log(
|
||
`[teardownVideoElement] Replaced modal video node (readyState=${clone.readyState} networkState=${clone.networkState}).`
|
||
);
|
||
|
||
return clone;
|
||
}
|
||
|
||
registerUrlPlaybackWatchdogs(
|
||
videoElement,
|
||
{ stallMs = 8000, onSuccess, onFallback } = {}
|
||
) {
|
||
this.cleanupUrlPlaybackWatchdog();
|
||
|
||
if (!videoElement || typeof onFallback !== "function") {
|
||
return () => {};
|
||
}
|
||
|
||
const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0;
|
||
this.log(
|
||
`[registerUrlPlaybackWatchdogs] Installing watchdogs (stallMs=${normalizedStallMs}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||
);
|
||
let active = true;
|
||
let stallTimerId = null;
|
||
|
||
const listeners = [];
|
||
|
||
const cleanup = () => {
|
||
if (!active) {
|
||
return;
|
||
}
|
||
active = false;
|
||
if (stallTimerId) {
|
||
clearTimeout(stallTimerId);
|
||
stallTimerId = null;
|
||
}
|
||
for (const [eventName, handler] of listeners) {
|
||
videoElement.removeEventListener(eventName, handler);
|
||
}
|
||
this.urlPlaybackWatchdogCleanup = null;
|
||
};
|
||
|
||
const triggerFallback = (reason) => {
|
||
if (!active) {
|
||
return;
|
||
}
|
||
this.log(
|
||
`[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||
);
|
||
cleanup();
|
||
onFallback(reason);
|
||
};
|
||
|
||
const handleSuccess = () => {
|
||
if (!active) {
|
||
return;
|
||
}
|
||
this.log(
|
||
"[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs."
|
||
);
|
||
cleanup();
|
||
if (typeof onSuccess === "function") {
|
||
onSuccess();
|
||
}
|
||
};
|
||
|
||
const resetTimer = () => {
|
||
if (!active || !normalizedStallMs) {
|
||
return;
|
||
}
|
||
if (stallTimerId) {
|
||
clearTimeout(stallTimerId);
|
||
}
|
||
stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs);
|
||
};
|
||
|
||
const addListener = (eventName, handler) => {
|
||
videoElement.addEventListener(eventName, handler);
|
||
listeners.push([eventName, handler]);
|
||
};
|
||
|
||
addListener("error", () => triggerFallback("error"));
|
||
addListener("abort", () => triggerFallback("abort"));
|
||
addListener("stalled", () => triggerFallback("stalled"));
|
||
addListener("playing", handleSuccess);
|
||
addListener("ended", handleSuccess);
|
||
|
||
const timerEvents = [
|
||
"timeupdate",
|
||
"progress",
|
||
"loadeddata",
|
||
"canplay",
|
||
"canplaythrough",
|
||
"suspend",
|
||
"waiting",
|
||
];
|
||
for (const eventName of timerEvents) {
|
||
addListener(eventName, resetTimer);
|
||
}
|
||
|
||
if (normalizedStallMs) {
|
||
resetTimer();
|
||
}
|
||
|
||
this.urlPlaybackWatchdogCleanup = () => {
|
||
this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked.");
|
||
cleanup();
|
||
};
|
||
|
||
return this.urlPlaybackWatchdogCleanup;
|
||
}
|
||
|
||
resetTorrentStats() {
|
||
if (this.modalPeers) {
|
||
this.modalPeers.textContent = "";
|
||
}
|
||
if (this.modalSpeed) {
|
||
this.modalSpeed.textContent = "";
|
||
}
|
||
if (this.modalDownloaded) {
|
||
this.modalDownloaded.textContent = "";
|
||
}
|
||
if (this.modalProgress) {
|
||
this.modalProgress.style.width = "0%";
|
||
}
|
||
}
|
||
|
||
setCopyMagnetState(enabled) {
|
||
if (!this.copyMagnetBtn) {
|
||
return;
|
||
}
|
||
this.copyMagnetBtn.disabled = !enabled;
|
||
this.copyMagnetBtn.setAttribute("aria-disabled", (!enabled).toString());
|
||
this.copyMagnetBtn.classList.toggle("opacity-50", !enabled);
|
||
this.copyMagnetBtn.classList.toggle("cursor-not-allowed", !enabled);
|
||
}
|
||
|
||
setShareButtonState(enabled) {
|
||
if (!this.shareBtn) {
|
||
return;
|
||
}
|
||
this.shareBtn.disabled = !enabled;
|
||
this.shareBtn.setAttribute("aria-disabled", (!enabled).toString());
|
||
this.shareBtn.classList.toggle("opacity-50", !enabled);
|
||
this.shareBtn.classList.toggle("cursor-not-allowed", !enabled);
|
||
}
|
||
|
||
setModalZapVisibility(visible) {
|
||
if (!this.modalZapBtn) {
|
||
return;
|
||
}
|
||
const shouldShow = !!visible;
|
||
this.modalZapBtn.classList.toggle("hidden", !shouldShow);
|
||
this.modalZapBtn.disabled = !shouldShow;
|
||
this.modalZapBtn.setAttribute("aria-disabled", (!shouldShow).toString());
|
||
this.modalZapBtn.setAttribute("aria-hidden", (!shouldShow).toString());
|
||
if (shouldShow) {
|
||
this.modalZapBtn.removeAttribute("tabindex");
|
||
} else {
|
||
this.modalZapBtn.setAttribute("tabindex", "-1");
|
||
}
|
||
}
|
||
|
||
getShareUrlBase() {
|
||
try {
|
||
const current = new URL(window.location.href);
|
||
return `${current.origin}${current.pathname}`;
|
||
} catch (err) {
|
||
const origin = window.location?.origin || "";
|
||
const pathname = window.location?.pathname || "";
|
||
if (origin || pathname) {
|
||
return `${origin}${pathname}`;
|
||
}
|
||
const href = window.location?.href || "";
|
||
if (href) {
|
||
const base = href.split(/[?#]/)[0];
|
||
if (base) {
|
||
return base;
|
||
}
|
||
}
|
||
console.warn("Unable to determine share URL base:", err);
|
||
return "";
|
||
}
|
||
}
|
||
|
||
buildShareUrlFromNevent(nevent) {
|
||
if (!nevent) {
|
||
return "";
|
||
}
|
||
const base = this.getShareUrlBase();
|
||
if (!base) {
|
||
return "";
|
||
}
|
||
return `${base}?v=${encodeURIComponent(nevent)}`;
|
||
}
|
||
|
||
buildShareUrlFromEventId(eventId) {
|
||
if (!eventId) {
|
||
return "";
|
||
}
|
||
|
||
try {
|
||
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
|
||
return this.buildShareUrlFromNevent(nevent);
|
||
} catch (err) {
|
||
console.error("Error generating nevent for share URL:", err);
|
||
return "";
|
||
}
|
||
}
|
||
|
||
dedupeVideosByRoot(videos) {
|
||
if (!Array.isArray(videos) || videos.length === 0) {
|
||
return [];
|
||
}
|
||
return dedupeToNewestByRoot(videos);
|
||
}
|
||
|
||
prepareModalVideoForPlayback() {
|
||
if (!this.modalVideo) {
|
||
return;
|
||
}
|
||
|
||
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
|
||
const userWantsUnmuted = storedUnmuted === "true";
|
||
this.modalVideo.muted = !userWantsUnmuted;
|
||
|
||
if (!this.modalVideo.dataset.autoplayBound) {
|
||
this.modalVideo.addEventListener("volumechange", () => {
|
||
localStorage.setItem(
|
||
"unmutedAutoplay",
|
||
(!this.modalVideo.muted).toString()
|
||
);
|
||
});
|
||
this.modalVideo.dataset.autoplayBound = "true";
|
||
}
|
||
}
|
||
|
||
autoplayModalVideo() {
|
||
if (!this.modalVideo) return;
|
||
this.modalVideo.play().catch((err) => {
|
||
this.log("Autoplay failed:", err);
|
||
if (!this.modalVideo.muted) {
|
||
this.log("Falling back to muted autoplay.");
|
||
this.modalVideo.muted = true;
|
||
this.modalVideo.play().catch((err2) => {
|
||
this.log("Muted autoplay also failed:", err2);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
startTorrentStatusMirrors(torrentInstance) {
|
||
if (!torrentInstance) {
|
||
return;
|
||
}
|
||
|
||
const updateInterval = setInterval(() => {
|
||
if (!document.body.contains(this.modalVideo)) {
|
||
clearInterval(updateInterval);
|
||
return;
|
||
}
|
||
this.updateTorrentStatus(torrentInstance);
|
||
}, 3000);
|
||
this.activeIntervals.push(updateInterval);
|
||
|
||
const mirrorInterval = setInterval(() => {
|
||
if (!document.body.contains(this.modalVideo)) {
|
||
clearInterval(mirrorInterval);
|
||
return;
|
||
}
|
||
const status = document.getElementById("status");
|
||
const progress = document.getElementById("progress");
|
||
const peers = document.getElementById("peers");
|
||
const speed = document.getElementById("speed");
|
||
const downloaded = document.getElementById("downloaded");
|
||
if (status && this.modalStatus) {
|
||
this.modalStatus.textContent = status.textContent;
|
||
}
|
||
if (progress && this.modalProgress) {
|
||
this.modalProgress.style.width = progress.style.width;
|
||
}
|
||
if (peers && this.modalPeers) {
|
||
this.modalPeers.textContent = peers.textContent;
|
||
}
|
||
if (speed && this.modalSpeed) {
|
||
this.modalSpeed.textContent = speed.textContent;
|
||
}
|
||
if (downloaded && this.modalDownloaded) {
|
||
this.modalDownloaded.textContent = downloaded.textContent;
|
||
}
|
||
}, 3000);
|
||
this.activeIntervals.push(mirrorInterval);
|
||
}
|
||
|
||
/**
|
||
* Hide the video modal.
|
||
*/
|
||
async hideModal() {
|
||
// 1) Clear intervals, cleanup, etc. (unchanged)
|
||
this.cancelPendingViewLogging();
|
||
this.clearActiveIntervals();
|
||
this.teardownModalViewCountSubscription();
|
||
|
||
try {
|
||
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
|
||
} catch (err) {
|
||
// ignore
|
||
}
|
||
await this.cleanup({
|
||
preserveSubscriptions: true,
|
||
preserveObservers: true,
|
||
});
|
||
|
||
// 2) Hide the modal
|
||
if (this.playerModal) {
|
||
this.playerModal.style.display = "none";
|
||
this.playerModal.classList.add("hidden");
|
||
}
|
||
document.body.classList.remove("modal-open");
|
||
document.documentElement.classList.remove("modal-open");
|
||
if (typeof this.modalPosterCleanup === "function") {
|
||
this.modalPosterCleanup();
|
||
this.modalPosterCleanup = null;
|
||
}
|
||
if (this.modalVideo) {
|
||
this.modalVideo.poster = "";
|
||
this.modalVideo.removeAttribute("poster");
|
||
}
|
||
this.currentMagnetUri = null;
|
||
|
||
// 3) Remove only `?v=` but **keep** the hash
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete("v"); // remove ?v= param
|
||
const newUrl = url.pathname + url.search + url.hash;
|
||
window.history.replaceState({}, "", newUrl);
|
||
}
|
||
|
||
/**
|
||
* Subscribe to videos (older + new) and render them as they come in.
|
||
*/
|
||
async loadVideos(forceFetch = false) {
|
||
console.log("Starting loadVideos... (forceFetch =", forceFetch, ")");
|
||
|
||
try {
|
||
await accessControl.ensureReady();
|
||
} catch (error) {
|
||
console.warn("Failed to ensure admin lists were loaded before fetching videos:", error);
|
||
}
|
||
|
||
const shouldIncludeVideo = (video) => {
|
||
if (!video || typeof video !== "object") {
|
||
return false;
|
||
}
|
||
|
||
if (this.blacklistedEventIds.has(video.id)) {
|
||
return false;
|
||
}
|
||
|
||
if (this.isAuthorBlocked(video.pubkey)) {
|
||
return false;
|
||
}
|
||
|
||
if (!accessControl.canAccess(video)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
// If forceFetch is true, unsubscribe from the old subscription to start fresh
|
||
if (forceFetch && this.videoSubscription) {
|
||
// Call unsubscribe on the subscription object directly.
|
||
this.videoSubscription.unsub();
|
||
this.videoSubscription = null;
|
||
}
|
||
|
||
// The rest of your existing logic:
|
||
if (!this.videoSubscription) {
|
||
if (this.videoList) {
|
||
this.lastRenderedVideoSignature = null;
|
||
this.videoList.innerHTML = getSidebarLoadingMarkup(
|
||
"Fetching recent videos…"
|
||
);
|
||
}
|
||
|
||
// Create a new subscription
|
||
this.videoSubscription = nostrClient.subscribeVideos(() => {
|
||
const updatedAll = nostrClient.getActiveVideos();
|
||
|
||
// Filter out blacklisted authors & blacklisted event IDs
|
||
const filteredVideos = updatedAll.filter(shouldIncludeVideo);
|
||
|
||
this.renderVideoList(filteredVideos);
|
||
});
|
||
|
||
const cachedActiveVideos = nostrClient.getActiveVideos();
|
||
const cachedFiltered = cachedActiveVideos.filter(shouldIncludeVideo);
|
||
if (cachedFiltered.length) {
|
||
this.renderVideoList(cachedFiltered);
|
||
}
|
||
|
||
if (this.videoSubscription) {
|
||
console.log(
|
||
"[loadVideos] subscription remains open to get live updates."
|
||
);
|
||
}
|
||
} else {
|
||
// Already subscribed: just show what's cached
|
||
const allCached = nostrClient.getActiveVideos();
|
||
|
||
const filteredCached = allCached.filter(shouldIncludeVideo);
|
||
|
||
this.renderVideoList(filteredCached);
|
||
}
|
||
}
|
||
|
||
async loadOlderVideos(lastTimestamp) {
|
||
// 1) Use nostrClient to fetch older slices
|
||
const olderVideos = await nostrClient.fetchOlderVideos(lastTimestamp);
|
||
|
||
if (!olderVideos || olderVideos.length === 0) {
|
||
this.showSuccess("No more older videos found.");
|
||
return;
|
||
}
|
||
|
||
// 2) Merge them into the client’s allEvents / activeMap
|
||
for (const v of olderVideos) {
|
||
nostrClient.allEvents.set(v.id, v);
|
||
// If it’s the newest version for its root, update activeMap
|
||
const rootKey = v.videoRootId || v.id;
|
||
// You can call getActiveKey(v) if you want to match your code’s approach.
|
||
// Then re-check if this one is newer than what’s stored, etc.
|
||
}
|
||
|
||
// 3) Re-render
|
||
const all = nostrClient.getActiveVideos();
|
||
this.renderVideoList(all);
|
||
}
|
||
|
||
/**
|
||
* Returns true if there's at least one strictly older version
|
||
* (same videoRootId, created_at < current) which is NOT deleted.
|
||
*/
|
||
hasOlderVersion(video, allEvents) {
|
||
if (!video || !video.videoRootId) return false;
|
||
|
||
const rootId = video.videoRootId;
|
||
const currentTs = video.created_at;
|
||
|
||
// among ALL known events (including overshadowed), find older, not deleted
|
||
const olderMatches = allEvents.filter(
|
||
(v) => v.videoRootId === rootId && v.created_at < currentTs && !v.deleted
|
||
);
|
||
return olderMatches.length > 0;
|
||
}
|
||
|
||
/**
|
||
* Centralised helper for other modules (channel profiles, subscriptions)
|
||
* so they can re-use the exact same badge skeleton. Keeping the markup in
|
||
* one place avoids subtle mismatches when we tweak copy or classes later.
|
||
*/
|
||
getUrlHealthPlaceholderMarkup(options = {}) {
|
||
const includeMargin = options?.includeMargin !== false;
|
||
const classes = [
|
||
"url-health-badge",
|
||
"text-xs",
|
||
"font-semibold",
|
||
"px-2",
|
||
"py-1",
|
||
"rounded",
|
||
"inline-flex",
|
||
"items-center",
|
||
"gap-1",
|
||
"bg-gray-800",
|
||
"text-gray-300",
|
||
];
|
||
if (includeMargin) {
|
||
classes.splice(1, 0, "mt-3");
|
||
}
|
||
|
||
return `
|
||
<div
|
||
class="${classes.join(" ")}"
|
||
data-url-health-state="checking"
|
||
aria-live="polite"
|
||
role="status"
|
||
>
|
||
Checking hosted URL…
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
getTorrentHealthBadgeMarkup(options = {}) {
|
||
const includeMargin = options?.includeMargin !== false;
|
||
const classes = [
|
||
"torrent-health-badge",
|
||
"text-xs",
|
||
"font-semibold",
|
||
"px-2",
|
||
"py-1",
|
||
"rounded",
|
||
"inline-flex",
|
||
"items-center",
|
||
"gap-1",
|
||
"bg-gray-800",
|
||
"text-gray-300",
|
||
"transition-colors",
|
||
"duration-200",
|
||
];
|
||
if (includeMargin) {
|
||
classes.unshift("mt-3");
|
||
}
|
||
|
||
return `
|
||
<div
|
||
class="${classes.join(" ")}"
|
||
data-stream-health-state="checking"
|
||
aria-live="polite"
|
||
role="status"
|
||
>
|
||
⏳ Torrent
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
isMagnetUriSupported(magnet) {
|
||
return isValidMagnetUri(magnet);
|
||
}
|
||
|
||
getCachedUrlHealth(eventId, url) {
|
||
return readUrlHealthFromCache(eventId, url);
|
||
}
|
||
|
||
storeUrlHealth(eventId, url, result, ttlMs) {
|
||
return writeUrlHealthToCache(eventId, url, result, ttlMs);
|
||
}
|
||
|
||
updateUrlHealthBadge(badgeEl, state, videoId) {
|
||
if (!badgeEl) {
|
||
return;
|
||
}
|
||
|
||
if (videoId && badgeEl.dataset.urlHealthFor && badgeEl.dataset.urlHealthFor !== videoId) {
|
||
return;
|
||
}
|
||
|
||
if (!badgeEl.isConnected) {
|
||
if (typeof requestAnimationFrame === "function") {
|
||
requestAnimationFrame(() => {
|
||
if (badgeEl.isConnected) {
|
||
this.updateUrlHealthBadge(badgeEl, state, videoId);
|
||
}
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
const status = state?.status || "checking";
|
||
const message =
|
||
state?.message ||
|
||
(status === "healthy"
|
||
? "✅ CDN"
|
||
: status === "offline"
|
||
? "❌ CDN"
|
||
: status === "unknown"
|
||
? "⚠️ CDN"
|
||
: status === "timeout"
|
||
? "⚠️ CDN timed out"
|
||
: "Checking hosted URL…");
|
||
|
||
const hadMargin = badgeEl.classList.contains("mt-3");
|
||
|
||
badgeEl.dataset.urlHealthState = status;
|
||
const cardEl = badgeEl.closest(".video-card");
|
||
if (cardEl) {
|
||
cardEl.dataset.urlHealthState = status;
|
||
}
|
||
badgeEl.setAttribute("aria-live", "polite");
|
||
badgeEl.setAttribute("role", status === "offline" ? "alert" : "status");
|
||
badgeEl.textContent = message;
|
||
|
||
const baseClasses = [
|
||
"url-health-badge",
|
||
"text-xs",
|
||
"font-semibold",
|
||
"px-2",
|
||
"py-1",
|
||
"rounded",
|
||
"transition-colors",
|
||
"duration-200",
|
||
];
|
||
if (hadMargin) {
|
||
baseClasses.unshift("mt-3");
|
||
}
|
||
badgeEl.className = baseClasses.join(" ");
|
||
|
||
if (status === "healthy") {
|
||
badgeEl.classList.add(
|
||
"inline-flex",
|
||
"items-center",
|
||
"gap-1",
|
||
"bg-green-900",
|
||
"text-green-200"
|
||
);
|
||
} else if (status === "offline") {
|
||
badgeEl.classList.add(
|
||
"inline-flex",
|
||
"items-center",
|
||
"gap-1",
|
||
"bg-red-900",
|
||
"text-red-200"
|
||
);
|
||
} else if (status === "unknown" || status === "timeout") {
|
||
badgeEl.classList.add(
|
||
"inline-flex",
|
||
"items-center",
|
||
"gap-1",
|
||
"bg-amber-900",
|
||
"text-amber-200"
|
||
);
|
||
} else {
|
||
badgeEl.classList.add(
|
||
"inline-flex",
|
||
"items-center",
|
||
"gap-1",
|
||
"bg-gray-800",
|
||
"text-gray-300"
|
||
);
|
||
}
|
||
}
|
||
|
||
handleUrlHealthBadge({ video, url, badgeEl }) {
|
||
if (!video?.id || !badgeEl || !url) {
|
||
return;
|
||
}
|
||
|
||
const eventId = video.id;
|
||
const trimmedUrl = typeof url === "string" ? url.trim() : "";
|
||
if (!trimmedUrl) {
|
||
return;
|
||
}
|
||
|
||
badgeEl.dataset.urlHealthFor = eventId;
|
||
|
||
const cached = this.getCachedUrlHealth(eventId, trimmedUrl);
|
||
if (cached) {
|
||
this.updateUrlHealthBadge(badgeEl, cached, eventId);
|
||
return;
|
||
}
|
||
|
||
this.updateUrlHealthBadge(badgeEl, { status: "checking" }, eventId);
|
||
|
||
const probeOptions = { confirmPlayable: true };
|
||
const existingProbe = getInFlightUrlProbe(eventId, trimmedUrl, probeOptions);
|
||
if (existingProbe) {
|
||
existingProbe
|
||
.then((entry) => {
|
||
if (entry) {
|
||
this.updateUrlHealthBadge(badgeEl, entry, eventId);
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.warn(
|
||
`[urlHealth] cached probe promise rejected for ${trimmedUrl}:`,
|
||
err
|
||
);
|
||
});
|
||
return;
|
||
}
|
||
|
||
const probePromise = this.probeUrl(trimmedUrl, probeOptions)
|
||
.then((result) => {
|
||
const outcome = result?.outcome || "error";
|
||
let entry;
|
||
|
||
if (outcome === "ok") {
|
||
entry = { status: "healthy", message: "✅ CDN" };
|
||
} else if (outcome === "opaque" || outcome === "unknown") {
|
||
entry = {
|
||
status: "unknown",
|
||
message: "⚠️ CDN",
|
||
};
|
||
} else if (outcome === "timeout") {
|
||
entry = {
|
||
status: "timeout",
|
||
message: "⚠️ CDN timed out",
|
||
};
|
||
} else {
|
||
entry = {
|
||
status: "offline",
|
||
message: "❌ CDN",
|
||
};
|
||
}
|
||
|
||
const ttlOverride =
|
||
entry.status === "timeout" || entry.status === "unknown"
|
||
? URL_HEALTH_TIMEOUT_RETRY_MS
|
||
: undefined;
|
||
|
||
return this.storeUrlHealth(eventId, trimmedUrl, entry, ttlOverride);
|
||
})
|
||
.catch((err) => {
|
||
console.warn(`[urlHealth] probe failed for ${trimmedUrl}:`, err);
|
||
const entry = {
|
||
status: "offline",
|
||
message: "❌ CDN",
|
||
};
|
||
return this.storeUrlHealth(eventId, trimmedUrl, entry);
|
||
});
|
||
|
||
setInFlightUrlProbe(eventId, trimmedUrl, probePromise, probeOptions);
|
||
|
||
probePromise
|
||
.then((entry) => {
|
||
if (entry) {
|
||
this.updateUrlHealthBadge(badgeEl, entry, eventId);
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.warn(
|
||
`[urlHealth] probe promise rejected post-cache for ${trimmedUrl}:`,
|
||
err
|
||
);
|
||
});
|
||
}
|
||
|
||
async renderVideoList(videos) {
|
||
if (!this.videoList) return;
|
||
|
||
if (this._lastRenderedVideoListElement !== this.videoList) {
|
||
this.lastRenderedVideoSignature = null;
|
||
this._lastRenderedVideoListElement = this.videoList;
|
||
}
|
||
|
||
const dedupedVideos = this.dedupeVideosByRoot(videos);
|
||
|
||
const activePointerKeys = new Set();
|
||
dedupedVideos.forEach((video) => {
|
||
const pointerInfo = this.deriveVideoPointerInfo(video);
|
||
if (pointerInfo) {
|
||
activePointerKeys.add(pointerInfo.key);
|
||
}
|
||
});
|
||
|
||
const keysToRemove = [];
|
||
for (const [key, entry] of this.viewCountSubscriptions.entries()) {
|
||
if (!activePointerKeys.has(key)) {
|
||
keysToRemove.push(key);
|
||
continue;
|
||
}
|
||
if (entry && entry.elements instanceof Set) {
|
||
entry.elements.clear();
|
||
}
|
||
}
|
||
|
||
keysToRemove.forEach((key) => {
|
||
const entry = this.viewCountSubscriptions.get(key);
|
||
if (entry && entry.pointer && entry.token) {
|
||
try {
|
||
unsubscribeFromVideoViewCount(entry.pointer, entry.token);
|
||
} catch (error) {
|
||
console.warn(
|
||
`[viewCount] Failed to unsubscribe from stale pointer ${key}:`,
|
||
error
|
||
);
|
||
}
|
||
}
|
||
this.viewCountSubscriptions.delete(key);
|
||
});
|
||
|
||
if (this.loadedThumbnails && this.loadedThumbnails.size) {
|
||
const activeIds = new Set();
|
||
dedupedVideos.forEach((video) => {
|
||
if (video && typeof video.id === "string" && video.id) {
|
||
activeIds.add(video.id);
|
||
}
|
||
});
|
||
Array.from(this.loadedThumbnails.keys()).forEach((videoId) => {
|
||
if (!activeIds.has(videoId)) {
|
||
this.loadedThumbnails.delete(videoId);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 1) If no videos
|
||
if (!dedupedVideos.length) {
|
||
this.renderedVideoIds.clear();
|
||
if (this.lastRenderedVideoSignature === EMPTY_VIDEO_LIST_SIGNATURE) {
|
||
return;
|
||
}
|
||
this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE;
|
||
this.videoList.innerHTML = `
|
||
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
||
No public videos available yet. Be the first to upload one!
|
||
</p>`;
|
||
return;
|
||
}
|
||
|
||
// 2) Sort newest first
|
||
dedupedVideos.sort((a, b) => b.created_at - a.created_at);
|
||
|
||
const signaturePayload = dedupedVideos.map((video) => ({
|
||
id: typeof video.id === "string" ? video.id : "",
|
||
createdAt: Number.isFinite(video.created_at)
|
||
? video.created_at
|
||
: Number(video.created_at) || 0,
|
||
deleted: Boolean(video.deleted),
|
||
isPrivate: Boolean(video.isPrivate),
|
||
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
|
||
url: typeof video.url === "string" ? video.url : "",
|
||
magnet: typeof video.magnet === "string" ? video.magnet : "",
|
||
enableComments: video.enableComments === false ? false : true,
|
||
}));
|
||
const signature = JSON.stringify(signaturePayload);
|
||
|
||
if (signature === this.lastRenderedVideoSignature) {
|
||
return;
|
||
}
|
||
this.lastRenderedVideoSignature = signature;
|
||
|
||
const previouslyRenderedIds = new Set(this.renderedVideoIds);
|
||
this.renderedVideoIds.clear();
|
||
|
||
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
|
||
const fragment = document.createDocumentFragment();
|
||
const authorSet = new Set();
|
||
const defaultShareBase =
|
||
this.getShareUrlBase() ||
|
||
`${window.location?.origin || ""}${window.location?.pathname || ""}` ||
|
||
(window.location?.href ? window.location.href.split(/[?#]/)[0] : "");
|
||
const canManageBlacklist = this.canCurrentUserManageBlacklist();
|
||
|
||
// 3) Build each card
|
||
dedupedVideos.forEach((video, index) => {
|
||
if (!video.id || !video.title) {
|
||
console.error("Video missing ID/title:", video);
|
||
return;
|
||
}
|
||
|
||
authorSet.add(video.pubkey);
|
||
|
||
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
|
||
const shareUrl =
|
||
this.buildShareUrlFromNevent(nevent) ||
|
||
`${defaultShareBase}?v=${encodeURIComponent(nevent)}`;
|
||
const canEdit = video.pubkey === this.pubkey;
|
||
const highlightClass =
|
||
video.isPrivate && canEdit
|
||
? "border-2 border-yellow-500"
|
||
: "border-none";
|
||
const isNewlyRendered = !previouslyRenderedIds.has(video.id);
|
||
const animationClass = isNewlyRendered ? "video-card--enter" : "";
|
||
const timeAgo = this.formatTimeAgo(video.created_at);
|
||
|
||
// Check if there's an older version
|
||
let hasOlder = false;
|
||
if (canEdit && video.videoRootId) {
|
||
hasOlder = this.hasOlderVersion(video, fullAllEventsArray);
|
||
}
|
||
|
||
const revertButton = hasOlder
|
||
? `
|
||
<button
|
||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||
data-revert-index="${index}"
|
||
data-revert-event-id="${video.id}"
|
||
>
|
||
Revert
|
||
</button>
|
||
`
|
||
: "";
|
||
|
||
const gearMenu = canEdit
|
||
? `
|
||
<div class="relative inline-block ml-3 overflow-visible">
|
||
<button
|
||
type="button"
|
||
class="inline-flex items-center p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
data-settings-dropdown="${index}"
|
||
>
|
||
<img
|
||
src="assets/svg/video-settings-gear.svg"
|
||
alt="Settings"
|
||
class="w-5 h-5"
|
||
/>
|
||
</button>
|
||
<div
|
||
id="settingsDropdown-${index}"
|
||
class="hidden absolute right-0 bottom-full mb-2 w-32 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||
>
|
||
<div class="py-1">
|
||
<button
|
||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||
data-edit-index="${index}"
|
||
data-edit-event-id="${video.id}"
|
||
>
|
||
Edit
|
||
</button>
|
||
${revertButton}
|
||
<button
|
||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||
data-delete-all-index="${index}"
|
||
data-delete-all-event-id="${video.id}"
|
||
>
|
||
Delete All
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
: "";
|
||
|
||
const blacklistMenuItem = canManageBlacklist
|
||
? `
|
||
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" data-action="blacklist-author" data-author="${video.pubkey || ""}">
|
||
Blacklist creator
|
||
</button>`
|
||
: "";
|
||
|
||
const moreMenu = `
|
||
<div class="relative inline-block ml-1 overflow-visible" data-more-menu-wrapper="true">
|
||
<button
|
||
type="button"
|
||
class="inline-flex items-center justify-center w-10 h-10 p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
data-more-dropdown="${index}"
|
||
aria-haspopup="true"
|
||
aria-expanded="false"
|
||
aria-label="More options"
|
||
>
|
||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5 object-contain" />
|
||
</button>
|
||
<div
|
||
id="moreDropdown-${index}"
|
||
class="hidden absolute right-0 bottom-full mb-2 w-40 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||
role="menu"
|
||
data-more-menu="true"
|
||
>
|
||
<div class="py-1">
|
||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="open-channel" data-author="${video.pubkey || ""}">
|
||
Open channel
|
||
</button>
|
||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="copy-link" data-event-id="${video.id || ""}">
|
||
Copy link
|
||
</button>
|
||
${blacklistMenuItem}
|
||
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" data-action="block-author" data-author="${video.pubkey || ""}">
|
||
Block creator
|
||
</button>
|
||
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="report" data-event-id="${video.id || ""}">
|
||
Report
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const cardControls = `
|
||
<div class="flex items-center">
|
||
${moreMenu}${gearMenu}
|
||
</div>
|
||
`;
|
||
|
||
const trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
|
||
const trimmedMagnet =
|
||
typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||
const legacyInfoHash =
|
||
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
|
||
const magnetCandidate = trimmedMagnet || legacyInfoHash;
|
||
const magnetSupported = isValidMagnetUri(magnetCandidate);
|
||
const magnetProvided = magnetCandidate.length > 0;
|
||
const playbackUrl = trimmedUrl;
|
||
const playbackMagnet = magnetCandidate;
|
||
const showUnsupportedTorrentBadge =
|
||
!trimmedUrl && magnetProvided && !magnetSupported;
|
||
const torrentWarningHtml = showUnsupportedTorrentBadge
|
||
? `
|
||
<p
|
||
class="mt-3 text-xs text-amber-300"
|
||
data-torrent-status="unsupported"
|
||
title="${UNSUPPORTED_BTITH_MESSAGE}"
|
||
>
|
||
WebTorrent fallback unavailable (magnet missing btih info hash)
|
||
</p>
|
||
`
|
||
: "";
|
||
|
||
const urlBadgeHtml = trimmedUrl
|
||
? this.getUrlHealthPlaceholderMarkup({ includeMargin: false })
|
||
: "";
|
||
const torrentHealthBadgeHtml =
|
||
magnetSupported && magnetProvided
|
||
? this.getTorrentHealthBadgeMarkup({ includeMargin: false })
|
||
: "";
|
||
const connectionBadgesHtml =
|
||
urlBadgeHtml || torrentHealthBadgeHtml
|
||
? `
|
||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||
${urlBadgeHtml}${torrentHealthBadgeHtml}
|
||
</div>
|
||
`
|
||
: "";
|
||
|
||
const showDiscussionCount = video.enableComments !== false;
|
||
let discussionCountHtml = "";
|
||
if (showDiscussionCount) {
|
||
let initialDiscussionCount = null;
|
||
if (typeof video.discussionCount === "number") {
|
||
initialDiscussionCount = video.discussionCount;
|
||
} else if (typeof video.discussionCount === "string") {
|
||
const trimmedCount = video.discussionCount.trim();
|
||
if (trimmedCount) {
|
||
const parsed = Number.parseInt(trimmedCount, 10);
|
||
if (Number.isFinite(parsed)) {
|
||
initialDiscussionCount = parsed;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (
|
||
initialDiscussionCount !== null &&
|
||
Number.isFinite(initialDiscussionCount) &&
|
||
initialDiscussionCount >= 0
|
||
) {
|
||
const safeDiscussionCount = Math.floor(initialDiscussionCount);
|
||
discussionCountHtml = `
|
||
<div class="flex items-center text-xs text-gray-500 mt-3" data-discussion-count="${video.id}" data-count-state="ready">
|
||
<span data-discussion-count-value>${safeDiscussionCount.toLocaleString()}</span>
|
||
<span class="ml-1">notes</span>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
const rawThumbnail =
|
||
typeof video.thumbnail === "string" ? video.thumbnail.trim() : "";
|
||
const escapedThumbnail = rawThumbnail
|
||
? this.escapeHTML(rawThumbnail)
|
||
: "";
|
||
const previouslyLoadedThumbnail =
|
||
this.loadedThumbnails.get(video.id) || "";
|
||
const shouldLazyLoadThumbnail =
|
||
escapedThumbnail && previouslyLoadedThumbnail !== escapedThumbnail;
|
||
const shouldAnimateThumbnail =
|
||
!!escapedThumbnail && previouslyLoadedThumbnail !== escapedThumbnail;
|
||
const thumbnailAttrLines = ["data-video-thumbnail=\"true\""];
|
||
if (shouldLazyLoadThumbnail) {
|
||
thumbnailAttrLines.push(
|
||
`src=\"${FALLBACK_THUMBNAIL_SRC}\"`,
|
||
`data-fallback-src=\"${FALLBACK_THUMBNAIL_SRC}\"`,
|
||
`data-lazy=\"${escapedThumbnail}\"`,
|
||
'loading="lazy"',
|
||
'decoding="async"'
|
||
);
|
||
} else {
|
||
thumbnailAttrLines.push(
|
||
`src=\"${escapedThumbnail || FALLBACK_THUMBNAIL_SRC}\"`,
|
||
`data-fallback-src=\"${FALLBACK_THUMBNAIL_SRC}\"`,
|
||
'loading="lazy"',
|
||
'decoding="async"'
|
||
);
|
||
}
|
||
thumbnailAttrLines.push(
|
||
`alt=\"${this.escapeHTML(video.title)}\"`
|
||
);
|
||
|
||
const pointerInfo = this.deriveVideoPointerInfo(video);
|
||
const metadataPieces = [`<span>${timeAgo}</span>`];
|
||
if (pointerInfo) {
|
||
metadataPieces.push(
|
||
'<span class="mx-1 text-gray-600" aria-hidden="true">•</span>',
|
||
`<span class="view-count-text" data-view-count data-view-pointer="${this.escapeHTML(
|
||
pointerInfo.key
|
||
)}">– views</span>`
|
||
);
|
||
}
|
||
const metadataHtml = metadataPieces.join("");
|
||
|
||
const cardHtml = `
|
||
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass} ${animationClass}">
|
||
<!-- The clickable link to play video -->
|
||
<a
|
||
href="${shareUrl}"
|
||
data-video-id="${video.id}"
|
||
data-play-url=""
|
||
data-play-magnet=""
|
||
data-torrent-supported="${magnetSupported ? "true" : "false"}"
|
||
class="block cursor-pointer relative group"
|
||
>
|
||
<div class="ratio-16-9">
|
||
<img
|
||
${thumbnailAttrLines.join("\n ")}
|
||
/>
|
||
</div>
|
||
</a>
|
||
<div class="p-4">
|
||
<!-- Title triggers the video modal as well -->
|
||
<h3
|
||
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
||
data-video-id="${video.id}"
|
||
data-play-url=""
|
||
data-play-magnet=""
|
||
data-torrent-supported="${magnetSupported ? "true" : "false"}"
|
||
>
|
||
${this.escapeHTML(video.title)}
|
||
</h3>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-3">
|
||
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden flex items-center justify-center">
|
||
<img
|
||
class="author-pic"
|
||
data-pubkey="${video.pubkey}"
|
||
src="assets/svg/default-profile.svg"
|
||
alt="Placeholder"
|
||
/>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<p
|
||
class="text-sm text-gray-400 author-name"
|
||
data-pubkey="${video.pubkey}"
|
||
>
|
||
Loading name...
|
||
</p>
|
||
<div class="flex items-center text-xs text-gray-500 mt-1">
|
||
${metadataHtml}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${cardControls}
|
||
</div>
|
||
${connectionBadgesHtml}
|
||
${discussionCountHtml}
|
||
${torrentWarningHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const template = document.createElement("template");
|
||
template.innerHTML = cardHtml.trim();
|
||
const cardEl = template.content.firstElementChild;
|
||
if (cardEl) {
|
||
cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
|
||
if (typeof video.pubkey === "string" && video.pubkey) {
|
||
cardEl.dataset.ownerPubkey = video.pubkey;
|
||
} else if (cardEl.dataset.ownerPubkey) {
|
||
delete cardEl.dataset.ownerPubkey;
|
||
}
|
||
|
||
if (trimmedUrl) {
|
||
cardEl.dataset.urlHealthState = "checking";
|
||
if (cardEl.dataset.urlHealthReason) {
|
||
delete cardEl.dataset.urlHealthReason;
|
||
}
|
||
cardEl.dataset.urlHealthEventId = video.id || "";
|
||
cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
|
||
} else {
|
||
cardEl.dataset.urlHealthState = "offline";
|
||
cardEl.dataset.urlHealthReason = "missing-source";
|
||
if (cardEl.dataset.urlHealthEventId) {
|
||
delete cardEl.dataset.urlHealthEventId;
|
||
}
|
||
if (cardEl.dataset.urlHealthUrl) {
|
||
delete cardEl.dataset.urlHealthUrl;
|
||
}
|
||
}
|
||
if (magnetProvided && magnetSupported) {
|
||
cardEl.dataset.streamHealthState = "checking";
|
||
if (cardEl.dataset.streamHealthReason) {
|
||
delete cardEl.dataset.streamHealthReason;
|
||
}
|
||
} else {
|
||
cardEl.dataset.streamHealthState = "unhealthy";
|
||
cardEl.dataset.streamHealthReason = magnetProvided
|
||
? "unsupported"
|
||
: "missing-source";
|
||
}
|
||
|
||
if (pointerInfo) {
|
||
this.registerVideoViewCountElement(cardEl, pointerInfo);
|
||
}
|
||
|
||
const thumbnailEl = cardEl.querySelector("[data-video-thumbnail]");
|
||
if (thumbnailEl) {
|
||
const markThumbnailAsLoaded = () => {
|
||
if (!escapedThumbnail) {
|
||
return;
|
||
}
|
||
|
||
if (shouldAnimateThumbnail) {
|
||
// Flag the element so CSS runs a one-time fade. js/index.js scrubs
|
||
// this attribute in the `animationend` handler so reusing the same
|
||
// DOM node later will not re-trigger the animation and flash.
|
||
if (thumbnailEl.dataset.thumbnailLoaded !== "true") {
|
||
thumbnailEl.dataset.thumbnailLoaded = "true";
|
||
}
|
||
} else if (thumbnailEl.dataset.thumbnailLoaded) {
|
||
delete thumbnailEl.dataset.thumbnailLoaded;
|
||
}
|
||
|
||
this.loadedThumbnails.set(video.id, escapedThumbnail);
|
||
};
|
||
|
||
const handleThumbnailLoad = () => {
|
||
if (thumbnailEl.dataset.thumbnailLoaded === "true") {
|
||
return;
|
||
}
|
||
|
||
const hasPendingLazySrc =
|
||
typeof thumbnailEl.dataset.lazy === "string" &&
|
||
thumbnailEl.dataset.lazy.trim().length > 0;
|
||
|
||
if (hasPendingLazySrc) {
|
||
return;
|
||
}
|
||
|
||
if (thumbnailEl.dataset.thumbnailFailed || !escapedThumbnail) {
|
||
return;
|
||
}
|
||
|
||
const fallbackAttr =
|
||
(typeof thumbnailEl.dataset.fallbackSrc === "string"
|
||
? thumbnailEl.dataset.fallbackSrc.trim()
|
||
: "") ||
|
||
thumbnailEl.getAttribute("data-fallback-src") ||
|
||
"";
|
||
|
||
const currentSrc = thumbnailEl.currentSrc || thumbnailEl.src || "";
|
||
const isFallbackSrc =
|
||
!!fallbackAttr &&
|
||
!!currentSrc &&
|
||
(currentSrc === fallbackAttr || currentSrc.endsWith(fallbackAttr));
|
||
|
||
if (isFallbackSrc) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
(thumbnailEl.naturalWidth === 0 &&
|
||
thumbnailEl.naturalHeight === 0) ||
|
||
!currentSrc
|
||
) {
|
||
return;
|
||
}
|
||
|
||
markThumbnailAsLoaded();
|
||
|
||
thumbnailEl.removeEventListener("load", handleThumbnailLoad);
|
||
};
|
||
const handleThumbnailError = () => {
|
||
if (
|
||
escapedThumbnail &&
|
||
this.loadedThumbnails.get(video.id) === escapedThumbnail
|
||
) {
|
||
this.loadedThumbnails.delete(video.id);
|
||
}
|
||
|
||
if (thumbnailEl.dataset.thumbnailLoaded) {
|
||
delete thumbnailEl.dataset.thumbnailLoaded;
|
||
}
|
||
|
||
thumbnailEl.removeEventListener("load", handleThumbnailLoad);
|
||
};
|
||
|
||
thumbnailEl.addEventListener("load", handleThumbnailLoad);
|
||
thumbnailEl.addEventListener("error", handleThumbnailError, {
|
||
once: true,
|
||
});
|
||
|
||
if (thumbnailEl.complete) {
|
||
handleThumbnailLoad();
|
||
}
|
||
}
|
||
|
||
if (showUnsupportedTorrentBadge) {
|
||
cardEl.dataset.torrentSupported = "false";
|
||
} else if (magnetProvided && magnetSupported) {
|
||
cardEl.dataset.torrentSupported = "true";
|
||
}
|
||
|
||
if (magnetProvided) {
|
||
cardEl.dataset.magnet = playbackMagnet;
|
||
} else if (cardEl.dataset.magnet) {
|
||
delete cardEl.dataset.magnet;
|
||
}
|
||
const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
|
||
// We intentionally leave the data-play-* attributes blank in cardHtml and
|
||
// assign them after template parsing so the raw URL/magnet strings avoid
|
||
// HTML entity escaping in the literal markup and keep any sensitive
|
||
// magnet payloads out of the static DOM text.
|
||
// The play URL is stored URL-encoded to keep spaces and query params
|
||
// intact inside data-* attributes; attachVideoListHandler() decodes it
|
||
// before playback.
|
||
interactiveEls.forEach((el) => {
|
||
if (!el.dataset) return;
|
||
el.dataset.playUrl = encodeURIComponent(playbackUrl || "");
|
||
el.dataset.playMagnet = playbackMagnet || "";
|
||
if (magnetProvided) {
|
||
el.dataset.torrentSupported = magnetSupported ? "true" : "false";
|
||
}
|
||
});
|
||
|
||
if (trimmedUrl) {
|
||
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
||
if (badgeEl) {
|
||
badgeEl.dataset.urlHealthEventId = video.id || "";
|
||
badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
|
||
}
|
||
} else {
|
||
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
||
if (badgeEl) {
|
||
if (badgeEl.dataset.urlHealthEventId) {
|
||
delete badgeEl.dataset.urlHealthEventId;
|
||
}
|
||
if (badgeEl.dataset.urlHealthUrl) {
|
||
delete badgeEl.dataset.urlHealthUrl;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (video && video.id) {
|
||
this.videosMap.set(video.id, video);
|
||
}
|
||
|
||
fragment.appendChild(cardEl);
|
||
this.renderedVideoIds.add(video.id);
|
||
});
|
||
|
||
// Clear old content, add new
|
||
this.videoList.innerHTML = "";
|
||
this.videoList.appendChild(fragment);
|
||
attachHealthBadges(this.videoList);
|
||
attachUrlHealthBadges(this.videoList, ({ badgeEl, url, eventId }) => {
|
||
const video = this.videosMap.get(eventId) || { id: eventId };
|
||
this.handleUrlHealthBadge({ video, url, badgeEl });
|
||
});
|
||
|
||
// Ensure every thumbnail can recover with a fallback image if the primary
|
||
// source fails to load or returns a zero-sized response (some CDNs error
|
||
// with HTTP 200 + empty body). We set up the listeners before kicking off
|
||
// any lazy-loading observers so cached failures are covered as well.
|
||
this.bindThumbnailFallbacks(this.videoList);
|
||
this.attachMoreMenuHandlers(this.videoList);
|
||
|
||
// Lazy-load images
|
||
const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
|
||
lazyEls.forEach((el) => this.mediaLoader.observe(el));
|
||
|
||
// GEAR MENU / button event listeners...
|
||
const gearButtons = this.videoList.querySelectorAll(
|
||
"[data-settings-dropdown]"
|
||
);
|
||
gearButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const index = button.getAttribute("data-settings-dropdown");
|
||
const dropdown = document.getElementById(`settingsDropdown-${index}`);
|
||
if (dropdown) {
|
||
dropdown.classList.toggle("hidden");
|
||
}
|
||
});
|
||
});
|
||
|
||
// Edit button
|
||
const editButtons = this.videoList.querySelectorAll("[data-edit-index]");
|
||
editButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const indexAttr = button.getAttribute("data-edit-index");
|
||
const eventId = button.getAttribute("data-edit-event-id") || "";
|
||
const index = Number.parseInt(indexAttr, 10);
|
||
const dropdown = document.getElementById(`settingsDropdown-${indexAttr}`);
|
||
if (dropdown) dropdown.classList.add("hidden");
|
||
this.handleEditVideo({
|
||
eventId,
|
||
index: Number.isNaN(index) ? null : index,
|
||
});
|
||
});
|
||
});
|
||
|
||
// Revert button
|
||
const revertButtons = this.videoList.querySelectorAll(
|
||
"[data-revert-index]"
|
||
);
|
||
revertButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const indexAttr = button.getAttribute("data-revert-index");
|
||
const eventId = button.getAttribute("data-revert-event-id") || "";
|
||
const index = Number.parseInt(indexAttr, 10);
|
||
const dropdown = document.getElementById(`settingsDropdown-${indexAttr}`);
|
||
if (dropdown) dropdown.classList.add("hidden");
|
||
this.handleRevertVideo({
|
||
eventId,
|
||
index: Number.isNaN(index) ? null : index,
|
||
});
|
||
});
|
||
});
|
||
|
||
// Delete All button
|
||
const deleteAllButtons = this.videoList.querySelectorAll(
|
||
"[data-delete-all-index]"
|
||
);
|
||
deleteAllButtons.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const indexAttr = button.getAttribute("data-delete-all-index");
|
||
const eventId = button.getAttribute("data-delete-all-event-id") || "";
|
||
const index = Number.parseInt(indexAttr, 10);
|
||
const dropdown = document.getElementById(`settingsDropdown-${indexAttr}`);
|
||
if (dropdown) dropdown.classList.add("hidden");
|
||
this.handleFullDeleteVideo({
|
||
eventId,
|
||
index: Number.isNaN(index) ? null : index,
|
||
});
|
||
});
|
||
});
|
||
|
||
// 2) After building cards, do one batch profile fetch
|
||
this.batchFetchProfiles(authorSet);
|
||
|
||
// === NEW: attach click listeners to .author-pic and .author-name
|
||
const authorPics = this.videoList.querySelectorAll(".author-pic");
|
||
authorPics.forEach((pic) => {
|
||
pic.style.cursor = "pointer";
|
||
pic.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation(); // avoids playing the video
|
||
const pubkey = pic.getAttribute("data-pubkey");
|
||
this.goToProfile(pubkey);
|
||
});
|
||
});
|
||
|
||
const authorNames = this.videoList.querySelectorAll(".author-name");
|
||
authorNames.forEach((nameEl) => {
|
||
nameEl.style.cursor = "pointer";
|
||
nameEl.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation(); // avoids playing the video
|
||
const pubkey = nameEl.getAttribute("data-pubkey");
|
||
this.goToProfile(pubkey);
|
||
});
|
||
});
|
||
|
||
this.refreshVideoDiscussionCounts(dedupedVideos);
|
||
this.pruneDetachedViewCountElements();
|
||
}
|
||
|
||
refreshVideoDiscussionCounts(videos = []) {
|
||
if (
|
||
!Array.isArray(videos) ||
|
||
!videos.length ||
|
||
!this.videoList ||
|
||
!nostrClient?.pool
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const eligible = videos
|
||
.filter(
|
||
(video) =>
|
||
video &&
|
||
typeof video.id === "string" &&
|
||
video.id &&
|
||
video.enableComments !== false
|
||
)
|
||
.slice(0, MAX_DISCUSSION_COUNT_VIDEOS);
|
||
|
||
eligible.forEach((video) => {
|
||
const container = this.videoList.querySelector(
|
||
`[data-discussion-count="${video.id}"]`
|
||
);
|
||
if (!container) {
|
||
return;
|
||
}
|
||
|
||
const cached = this.videoDiscussionCountCache.get(video.id);
|
||
if (typeof cached === "number") {
|
||
this.updateDiscussionCountElement(container, cached);
|
||
return;
|
||
}
|
||
|
||
const filters = this.buildDiscussionCountFilters(video);
|
||
if (!filters.length) {
|
||
this.markDiscussionCountError(container, { unsupported: true });
|
||
return;
|
||
}
|
||
|
||
const existingPromise = this.inFlightDiscussionCounts.get(video.id);
|
||
if (existingPromise) {
|
||
this.setDiscussionCountPending(container);
|
||
return;
|
||
}
|
||
|
||
this.setDiscussionCountPending(container);
|
||
|
||
const request = nostrClient
|
||
.countEventsAcrossRelays(filters)
|
||
.then((result) => {
|
||
const perRelay = Array.isArray(result?.perRelay)
|
||
? result.perRelay.filter((entry) => entry && entry.ok)
|
||
: [];
|
||
|
||
if (!perRelay.length) {
|
||
this.markDiscussionCountError(container, { unsupported: true });
|
||
return result;
|
||
}
|
||
|
||
const total = Number(result?.total);
|
||
const normalized =
|
||
Number.isFinite(total) && total >= 0 ? total : 0;
|
||
this.videoDiscussionCountCache.set(video.id, normalized);
|
||
this.updateDiscussionCountElement(container, normalized);
|
||
return result;
|
||
})
|
||
.catch((error) => {
|
||
if (isDevMode) {
|
||
console.warn(
|
||
`[counts] Failed to fetch discussion count for ${video.id}:`,
|
||
error
|
||
);
|
||
}
|
||
this.markDiscussionCountError(container);
|
||
throw error;
|
||
})
|
||
.finally(() => {
|
||
this.inFlightDiscussionCounts.delete(video.id);
|
||
});
|
||
|
||
this.inFlightDiscussionCounts.set(video.id, request);
|
||
});
|
||
}
|
||
|
||
buildDiscussionCountFilters(video) {
|
||
if (!video || typeof video !== "object") {
|
||
return [];
|
||
}
|
||
|
||
const filters = [];
|
||
const eventId =
|
||
typeof video.id === "string" ? video.id.trim() : "";
|
||
if (eventId) {
|
||
filters.push({ kinds: [1], "#e": [eventId] });
|
||
}
|
||
|
||
const address = this.getVideoAddressPointer(video);
|
||
if (address) {
|
||
filters.push({ kinds: [1], "#a": [address] });
|
||
}
|
||
|
||
return filters;
|
||
}
|
||
|
||
getVideoAddressPointer(video) {
|
||
if (!video || typeof video !== "object") {
|
||
return "";
|
||
}
|
||
|
||
const tags = Array.isArray(video.tags) ? video.tags : [];
|
||
const dTag = tags.find(
|
||
(tag) =>
|
||
Array.isArray(tag) &&
|
||
tag.length >= 2 &&
|
||
tag[0] === "d" &&
|
||
typeof tag[1] === "string" &&
|
||
tag[1].trim()
|
||
);
|
||
|
||
if (!dTag) {
|
||
return "";
|
||
}
|
||
|
||
const pubkey =
|
||
typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||
if (!pubkey) {
|
||
return "";
|
||
}
|
||
|
||
const identifier = dTag[1].trim();
|
||
if (!identifier) {
|
||
return "";
|
||
}
|
||
|
||
const kind =
|
||
Number.isFinite(video.kind) && video.kind > 0
|
||
? Math.floor(video.kind)
|
||
: VIDEO_EVENT_KIND;
|
||
|
||
return `${kind}:${pubkey}:${identifier}`;
|
||
}
|
||
|
||
setDiscussionCountPending(element) {
|
||
if (!element) {
|
||
return;
|
||
}
|
||
element.dataset.countState = "pending";
|
||
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||
if (valueEl) {
|
||
valueEl.textContent = "…";
|
||
}
|
||
element.removeAttribute("title");
|
||
}
|
||
|
||
updateDiscussionCountElement(element, count) {
|
||
if (!element) {
|
||
return;
|
||
}
|
||
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||
if (!valueEl) {
|
||
return;
|
||
}
|
||
const numeric = Number(count);
|
||
const safeValue =
|
||
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
|
||
element.dataset.countState = "ready";
|
||
valueEl.textContent = safeValue.toLocaleString();
|
||
element.removeAttribute("title");
|
||
}
|
||
|
||
markDiscussionCountError(element, { unsupported = false } = {}) {
|
||
if (!element) {
|
||
return;
|
||
}
|
||
const valueEl = element.querySelector("[data-discussion-count-value]");
|
||
if (valueEl) {
|
||
valueEl.textContent = "—";
|
||
}
|
||
element.dataset.countState = unsupported ? "unsupported" : "error";
|
||
if (unsupported) {
|
||
element.title = "Relay does not support NIP-45 COUNT queries.";
|
||
} else {
|
||
element.removeAttribute("title");
|
||
}
|
||
}
|
||
|
||
bindThumbnailFallbacks(container) {
|
||
if (!container || typeof container.querySelectorAll !== "function") {
|
||
return;
|
||
}
|
||
|
||
const thumbnails = container.querySelectorAll("[data-video-thumbnail]");
|
||
thumbnails.forEach((img) => {
|
||
if (!img) {
|
||
return;
|
||
}
|
||
|
||
const ensureFallbackSource = () => {
|
||
let fallbackSrc = "";
|
||
if (typeof img.dataset.fallbackSrc === "string") {
|
||
fallbackSrc = img.dataset.fallbackSrc.trim();
|
||
}
|
||
|
||
if (!fallbackSrc) {
|
||
const attr = img.getAttribute("data-fallback-src") || "";
|
||
fallbackSrc = attr.trim();
|
||
}
|
||
|
||
if (!fallbackSrc && img.tagName === "IMG") {
|
||
fallbackSrc = FALLBACK_THUMBNAIL_SRC;
|
||
}
|
||
|
||
if (fallbackSrc) {
|
||
if (img.dataset.fallbackSrc !== fallbackSrc) {
|
||
img.dataset.fallbackSrc = fallbackSrc;
|
||
}
|
||
if (!img.getAttribute("data-fallback-src")) {
|
||
img.setAttribute("data-fallback-src", fallbackSrc);
|
||
}
|
||
}
|
||
|
||
return fallbackSrc;
|
||
};
|
||
|
||
const applyFallback = () => {
|
||
const fallbackSrc = ensureFallbackSource() || FALLBACK_THUMBNAIL_SRC;
|
||
if (!fallbackSrc) {
|
||
return;
|
||
}
|
||
|
||
if (img.src !== fallbackSrc) {
|
||
img.src = fallbackSrc;
|
||
}
|
||
|
||
img.dataset.thumbnailFailed = "true";
|
||
};
|
||
|
||
const handleLoad = () => {
|
||
if (
|
||
(img.naturalWidth === 0 && img.naturalHeight === 0) ||
|
||
!img.currentSrc
|
||
) {
|
||
applyFallback();
|
||
} else {
|
||
delete img.dataset.thumbnailFailed;
|
||
}
|
||
};
|
||
|
||
ensureFallbackSource();
|
||
|
||
if (img.dataset.thumbnailFallbackBound === "true") {
|
||
if (img.complete) {
|
||
handleLoad();
|
||
}
|
||
return;
|
||
}
|
||
|
||
img.addEventListener("error", applyFallback);
|
||
img.addEventListener("load", handleLoad);
|
||
|
||
img.dataset.thumbnailFallbackBound = "true";
|
||
|
||
if (img.complete) {
|
||
handleLoad();
|
||
}
|
||
});
|
||
}
|
||
|
||
ensureGlobalMoreMenuHandlers() {
|
||
if (this.moreMenuGlobalHandlerBound) {
|
||
return;
|
||
}
|
||
|
||
this.moreMenuGlobalHandlerBound = true;
|
||
|
||
this.boundMoreMenuDocumentClick = (event) => {
|
||
const target = event.target;
|
||
if (
|
||
target instanceof HTMLElement &&
|
||
(target.closest("[data-more-menu-wrapper]") ||
|
||
target.closest("[data-more-menu]"))
|
||
) {
|
||
return;
|
||
}
|
||
this.closeAllMoreMenus();
|
||
};
|
||
|
||
this.boundMoreMenuDocumentKeydown = (event) => {
|
||
if (event.key === "Escape") {
|
||
this.closeAllMoreMenus();
|
||
}
|
||
};
|
||
|
||
document.addEventListener("click", this.boundMoreMenuDocumentClick);
|
||
document.addEventListener("keydown", this.boundMoreMenuDocumentKeydown);
|
||
}
|
||
|
||
closeAllMoreMenus() {
|
||
const menus = document.querySelectorAll("[data-more-menu]");
|
||
menus.forEach((menu) => {
|
||
if (menu instanceof HTMLElement) {
|
||
menu.classList.add("hidden");
|
||
}
|
||
});
|
||
|
||
const buttons = document.querySelectorAll("[data-more-dropdown]");
|
||
buttons.forEach((btn) => {
|
||
if (btn instanceof HTMLElement) {
|
||
btn.setAttribute("aria-expanded", "false");
|
||
}
|
||
});
|
||
}
|
||
|
||
attachMoreMenuHandlers(container) {
|
||
if (!container || typeof container.querySelectorAll !== "function") {
|
||
return;
|
||
}
|
||
|
||
const buttons = container.querySelectorAll("[data-more-dropdown]");
|
||
if (!buttons.length) {
|
||
return;
|
||
}
|
||
|
||
this.ensureGlobalMoreMenuHandlers();
|
||
|
||
buttons.forEach((button) => {
|
||
if (!(button instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
if (button.dataset.moreMenuToggleBound === "true") {
|
||
return;
|
||
}
|
||
button.dataset.moreMenuToggleBound = "true";
|
||
button.setAttribute("aria-expanded", "false");
|
||
|
||
button.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const key = button.getAttribute("data-more-dropdown") || "";
|
||
const dropdown = document.getElementById(`moreDropdown-${key}`);
|
||
if (!(dropdown instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const willOpen = dropdown.classList.contains("hidden");
|
||
this.closeAllMoreMenus();
|
||
|
||
if (willOpen) {
|
||
dropdown.classList.remove("hidden");
|
||
button.setAttribute("aria-expanded", "true");
|
||
}
|
||
});
|
||
});
|
||
|
||
const actionButtons = container.querySelectorAll(
|
||
"[data-more-menu] button[data-action]"
|
||
);
|
||
actionButtons.forEach((button) => {
|
||
if (!(button instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
if (button.dataset.moreMenuActionBound === "true") {
|
||
return;
|
||
}
|
||
button.dataset.moreMenuActionBound = "true";
|
||
|
||
button.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const { action } = button.dataset;
|
||
this.handleMoreMenuAction(action, button.dataset);
|
||
this.closeAllMoreMenus();
|
||
});
|
||
});
|
||
}
|
||
|
||
syncModalMoreMenuData() {
|
||
if (!this.modalMoreMenu) {
|
||
return;
|
||
}
|
||
|
||
const buttons = this.modalMoreMenu.querySelectorAll("button[data-action]");
|
||
buttons.forEach((button) => {
|
||
if (!(button instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const action = button.dataset.action || "";
|
||
if (action === "blacklist-author") {
|
||
const canShow = this.canCurrentUserManageBlacklist();
|
||
if (canShow && this.currentVideo && this.currentVideo.pubkey) {
|
||
button.dataset.author = this.currentVideo.pubkey;
|
||
button.classList.remove("hidden");
|
||
button.setAttribute("aria-hidden", "false");
|
||
} else {
|
||
delete button.dataset.author;
|
||
button.classList.add("hidden");
|
||
button.setAttribute("aria-hidden", "true");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (action === "open-channel" || action === "block-author") {
|
||
if (this.currentVideo && this.currentVideo.pubkey) {
|
||
button.dataset.author = this.currentVideo.pubkey;
|
||
} else {
|
||
delete button.dataset.author;
|
||
}
|
||
}
|
||
|
||
if (action === "copy-link" || action === "report") {
|
||
if (this.currentVideo && this.currentVideo.id) {
|
||
button.dataset.eventId = this.currentVideo.id;
|
||
} else {
|
||
delete button.dataset.eventId;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async handleMoreMenuAction(action, dataset = {}) {
|
||
const normalized = typeof action === "string" ? action.trim() : "";
|
||
const context = dataset.context || "";
|
||
|
||
switch (normalized) {
|
||
case "open-channel": {
|
||
if (context === "modal") {
|
||
this.openCreatorChannel();
|
||
break;
|
||
}
|
||
|
||
const author =
|
||
dataset.author || (this.currentVideo ? this.currentVideo.pubkey : "");
|
||
if (author) {
|
||
this.goToProfile(author);
|
||
} else {
|
||
this.showError("No creator info available.");
|
||
}
|
||
break;
|
||
}
|
||
case "copy-link": {
|
||
const eventId =
|
||
dataset.eventId ||
|
||
(context === "modal" && this.currentVideo ? this.currentVideo.id : "");
|
||
if (!eventId) {
|
||
this.showError("Could not generate link.");
|
||
break;
|
||
}
|
||
const shareUrl = this.buildShareUrlFromEventId(eventId);
|
||
if (!shareUrl) {
|
||
this.showError("Could not generate link.");
|
||
break;
|
||
}
|
||
navigator.clipboard
|
||
.writeText(shareUrl)
|
||
.then(() => this.showSuccess("Video link copied to clipboard!"))
|
||
.catch(() => this.showError("Failed to copy the link."));
|
||
break;
|
||
}
|
||
case "copy-npub": {
|
||
const explicitNpub =
|
||
typeof dataset.npub === "string" && dataset.npub.trim()
|
||
? dataset.npub.trim()
|
||
: "";
|
||
const authorCandidate = dataset.author || "";
|
||
const fallbackNpub = this.safeEncodeNpub(authorCandidate);
|
||
const valueToCopy = explicitNpub || fallbackNpub;
|
||
|
||
if (!valueToCopy) {
|
||
this.showError("No npub available to copy.");
|
||
break;
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(valueToCopy);
|
||
this.showSuccess("Channel npub copied to clipboard!");
|
||
} catch (error) {
|
||
console.error("Failed to copy npub:", error);
|
||
this.showError("Failed to copy the npub.");
|
||
}
|
||
break;
|
||
}
|
||
case "blacklist-author": {
|
||
const actorNpub = this.getCurrentUserNpub();
|
||
if (!actorNpub) {
|
||
this.showError("Please login as a moderator to manage the blacklist.");
|
||
break;
|
||
}
|
||
|
||
try {
|
||
await accessControl.ensureReady();
|
||
} catch (error) {
|
||
console.warn("Failed to refresh moderation state before blacklisting:", error);
|
||
}
|
||
|
||
if (!accessControl.canEditAdminLists(actorNpub)) {
|
||
this.showError("Only moderators can manage the blacklist.");
|
||
break;
|
||
}
|
||
|
||
let author = dataset.author || "";
|
||
if (!author && context === "modal" && this.currentVideo?.pubkey) {
|
||
author = this.currentVideo.pubkey;
|
||
}
|
||
if (!author && this.currentVideo?.pubkey) {
|
||
author = this.currentVideo.pubkey;
|
||
}
|
||
|
||
const explicitNpub =
|
||
typeof dataset.npub === "string" && dataset.npub.trim()
|
||
? dataset.npub.trim()
|
||
: "";
|
||
const targetNpub = explicitNpub || this.safeEncodeNpub(author);
|
||
|
||
if (!targetNpub) {
|
||
this.showError("Unable to determine the creator npub.");
|
||
break;
|
||
}
|
||
|
||
try {
|
||
const result = await accessControl.addToBlacklist(
|
||
actorNpub,
|
||
targetNpub
|
||
);
|
||
|
||
if (result?.ok) {
|
||
this.showSuccess("Creator added to the blacklist.");
|
||
} else {
|
||
const code = result?.error || "unknown";
|
||
switch (code) {
|
||
case "self":
|
||
this.showError("You cannot blacklist yourself.");
|
||
break;
|
||
case "immutable":
|
||
this.showError(
|
||
"Moderators cannot blacklist the super admin or fellow moderators."
|
||
);
|
||
break;
|
||
case "invalid npub":
|
||
this.showError("Unable to blacklist this creator.");
|
||
break;
|
||
case "forbidden":
|
||
this.showError("Only moderators can manage the blacklist.");
|
||
break;
|
||
default:
|
||
this.showError(
|
||
"Failed to update the blacklist. Please try again."
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to add creator to blacklist:", error);
|
||
this.showError("Failed to update the blacklist. Please try again.");
|
||
}
|
||
break;
|
||
}
|
||
case "block-author": {
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to manage your block list.");
|
||
break;
|
||
}
|
||
|
||
const authorCandidate =
|
||
dataset.author ||
|
||
(this.currentVideo && this.currentVideo.pubkey) ||
|
||
"";
|
||
|
||
const trimmed =
|
||
typeof authorCandidate === "string" ? authorCandidate.trim() : "";
|
||
if (!trimmed) {
|
||
this.showError("Unable to determine the creator to block.");
|
||
break;
|
||
}
|
||
|
||
let normalizedHex = "";
|
||
if (trimmed.startsWith("npub1")) {
|
||
normalizedHex = this.safeDecodeNpub(trimmed) || "";
|
||
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||
normalizedHex = trimmed.toLowerCase();
|
||
}
|
||
|
||
if (!normalizedHex) {
|
||
this.showError("Unable to determine the creator to block.");
|
||
break;
|
||
}
|
||
|
||
if (normalizedHex === this.pubkey) {
|
||
this.showError("You cannot block yourself.");
|
||
break;
|
||
}
|
||
|
||
try {
|
||
await userBlocks.ensureLoaded(this.pubkey);
|
||
|
||
if (userBlocks.isBlocked(normalizedHex)) {
|
||
this.showSuccess("You already blocked this creator.");
|
||
} else {
|
||
await userBlocks.addBlock(normalizedHex, this.pubkey);
|
||
this.showSuccess(
|
||
"Creator blocked. You won't see their videos anymore."
|
||
);
|
||
}
|
||
|
||
this.populateBlockedList();
|
||
await this.loadVideos();
|
||
} catch (error) {
|
||
console.error("Failed to update personal block list:", error);
|
||
const message =
|
||
error?.code === "nip04-missing"
|
||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||
: "Failed to update your block list. Please try again.";
|
||
this.showError(message);
|
||
}
|
||
break;
|
||
}
|
||
case "report": {
|
||
this.showSuccess("Reporting coming soon.");
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Updates the modal to reflect current torrent stats.
|
||
* We remove the unused torrent.status references,
|
||
* and do not re-trigger recursion here (no setTimeout).
|
||
*/
|
||
updateTorrentStatus(torrent) {
|
||
console.log("[DEBUG] updateTorrentStatus called with torrent:", torrent);
|
||
|
||
if (!torrent) {
|
||
console.log("[DEBUG] torrent is null/undefined!");
|
||
return;
|
||
}
|
||
|
||
if (torrent.ready || (typeof torrent.progress === "number" && torrent.progress > 0)) {
|
||
// Belt-and-suspenders: if WebTorrent reports progress but the DOM events
|
||
// failed to fire we still rip off the loading GIF. This regression has
|
||
// bitten us in past releases, so the extra clear is intentional.
|
||
this.forceRemoveModalPoster(
|
||
torrent.ready ? "torrent-ready-flag" : "torrent-progress"
|
||
);
|
||
}
|
||
|
||
// Log only fields that actually exist on the torrent:
|
||
console.log("[DEBUG] torrent.progress =", torrent.progress);
|
||
console.log("[DEBUG] torrent.numPeers =", torrent.numPeers);
|
||
console.log("[DEBUG] torrent.downloadSpeed =", torrent.downloadSpeed);
|
||
console.log("[DEBUG] torrent.downloaded =", torrent.downloaded);
|
||
console.log("[DEBUG] torrent.length =", torrent.length);
|
||
console.log("[DEBUG] torrent.ready =", torrent.ready);
|
||
|
||
// Use "Complete" vs. "Downloading" as the textual status.
|
||
if (this.modalStatus) {
|
||
const fullyDownloaded = torrent.progress >= 1;
|
||
this.modalStatus.textContent = fullyDownloaded
|
||
? "Complete"
|
||
: "Downloading";
|
||
}
|
||
|
||
// Update the progress bar
|
||
if (this.modalProgress) {
|
||
const percent = (torrent.progress * 100).toFixed(2);
|
||
this.modalProgress.style.width = `${percent}%`;
|
||
}
|
||
|
||
// Update peers count
|
||
if (this.modalPeers) {
|
||
this.modalPeers.textContent = `Peers: ${torrent.numPeers}`;
|
||
}
|
||
|
||
// Update speed in KB/s
|
||
if (this.modalSpeed) {
|
||
const kb = (torrent.downloadSpeed / 1024).toFixed(2);
|
||
this.modalSpeed.textContent = `${kb} KB/s`;
|
||
}
|
||
|
||
// Update downloaded / total
|
||
if (this.modalDownloaded) {
|
||
const downloadedMb = (torrent.downloaded / (1024 * 1024)).toFixed(2);
|
||
const lengthMb = (torrent.length / (1024 * 1024)).toFixed(2);
|
||
this.modalDownloaded.textContent = `${downloadedMb} MB / ${lengthMb} MB`;
|
||
}
|
||
|
||
// If you want to show a different text at 100% or if "ready"
|
||
// you can do it here:
|
||
if (torrent.ready && this.modalStatus) {
|
||
this.modalStatus.textContent = "Ready to play";
|
||
}
|
||
}
|
||
|
||
normalizeActionTarget(target) {
|
||
if (target && typeof target === "object") {
|
||
const eventId =
|
||
typeof target.eventId === "string" ? target.eventId.trim() : "";
|
||
let index = null;
|
||
if (typeof target.index === "number" && Number.isInteger(target.index)) {
|
||
index = target.index;
|
||
} else if (
|
||
typeof target.index === "string" && target.index.trim().length > 0
|
||
) {
|
||
const parsed = Number.parseInt(target.index.trim(), 10);
|
||
index = Number.isNaN(parsed) ? null : parsed;
|
||
}
|
||
return { eventId, index };
|
||
}
|
||
|
||
if (typeof target === "string") {
|
||
const trimmed = target.trim();
|
||
if (!trimmed) {
|
||
return { eventId: "", index: null };
|
||
}
|
||
if (/^-?\d+$/.test(trimmed)) {
|
||
const parsed = Number.parseInt(trimmed, 10);
|
||
return { eventId: "", index: Number.isNaN(parsed) ? null : parsed };
|
||
}
|
||
return { eventId: trimmed, index: null };
|
||
}
|
||
|
||
if (typeof target === "number" && Number.isInteger(target)) {
|
||
return { eventId: "", index: target };
|
||
}
|
||
|
||
return { eventId: "", index: null };
|
||
}
|
||
|
||
async resolveVideoActionTarget({
|
||
eventId = "",
|
||
index = null,
|
||
preloadedList,
|
||
} = {}) {
|
||
const trimmedEventId = typeof eventId === "string" ? eventId.trim() : "";
|
||
const normalizedIndex =
|
||
typeof index === "number" && Number.isInteger(index) && index >= 0
|
||
? index
|
||
: null;
|
||
|
||
const candidateLists = Array.isArray(preloadedList)
|
||
? [preloadedList]
|
||
: [];
|
||
|
||
for (const list of candidateLists) {
|
||
if (trimmedEventId) {
|
||
const match = list.find((video) => video?.id === trimmedEventId);
|
||
if (match) {
|
||
this.videosMap.set(match.id, match);
|
||
return match;
|
||
}
|
||
}
|
||
if (
|
||
normalizedIndex !== null &&
|
||
normalizedIndex >= 0 &&
|
||
normalizedIndex < list.length
|
||
) {
|
||
const match = list[normalizedIndex];
|
||
if (match) {
|
||
this.videosMap.set(match.id, match);
|
||
return match;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (trimmedEventId) {
|
||
const fromMap = this.videosMap.get(trimmedEventId);
|
||
if (fromMap) {
|
||
return fromMap;
|
||
}
|
||
|
||
const activeVideos = nostrClient.getActiveVideos();
|
||
const fromActive = activeVideos.find((video) => video.id === trimmedEventId);
|
||
if (fromActive) {
|
||
this.videosMap.set(fromActive.id, fromActive);
|
||
return fromActive;
|
||
}
|
||
|
||
const fromAll = nostrClient.allEvents.get(trimmedEventId);
|
||
if (fromAll) {
|
||
this.videosMap.set(fromAll.id, fromAll);
|
||
return fromAll;
|
||
}
|
||
|
||
const fetched = await nostrClient.getEventById(trimmedEventId);
|
||
if (fetched) {
|
||
this.videosMap.set(fetched.id, fetched);
|
||
return fetched;
|
||
}
|
||
}
|
||
|
||
if (normalizedIndex !== null) {
|
||
const activeVideos = nostrClient.getActiveVideos();
|
||
if (normalizedIndex >= 0 && normalizedIndex < activeVideos.length) {
|
||
const candidate = activeVideos[normalizedIndex];
|
||
if (candidate) {
|
||
this.videosMap.set(candidate.id, candidate);
|
||
return candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Handle "Edit Video" from gear menu.
|
||
*/
|
||
async handleEditVideo(target) {
|
||
try {
|
||
const normalizedTarget = this.normalizeActionTarget(target);
|
||
const latestVideos = await nostrClient.fetchVideos();
|
||
const video = await this.resolveVideoActionTarget({
|
||
...normalizedTarget,
|
||
preloadedList: latestVideos,
|
||
});
|
||
|
||
// 2) Basic ownership checks
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to edit videos.");
|
||
return;
|
||
}
|
||
const userPubkey = (this.pubkey || "").toLowerCase();
|
||
const videoPubkey = (video?.pubkey || "").toLowerCase();
|
||
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
|
||
this.showError("You do not own this video.");
|
||
return;
|
||
}
|
||
|
||
if (!this.editVideoModal) {
|
||
await this.initEditVideoModal();
|
||
}
|
||
|
||
if (!this.editVideoModal) {
|
||
this.showError("Edit modal is not available right now.");
|
||
return;
|
||
}
|
||
|
||
this.populateEditVideoForm(video);
|
||
this.showEditVideoModal();
|
||
} catch (err) {
|
||
console.error("Failed to edit video:", err);
|
||
this.showError("Failed to edit video. Please try again.");
|
||
}
|
||
}
|
||
|
||
async handleRevertVideo(target) {
|
||
try {
|
||
const normalizedTarget = this.normalizeActionTarget(target);
|
||
const activeVideos = await nostrClient.fetchVideos();
|
||
const video = await this.resolveVideoActionTarget({
|
||
...normalizedTarget,
|
||
preloadedList: activeVideos,
|
||
});
|
||
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to revert.");
|
||
return;
|
||
}
|
||
const userPubkey = (this.pubkey || "").toLowerCase();
|
||
const videoPubkey = (video?.pubkey || "").toLowerCase();
|
||
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
|
||
this.showError("You do not own this video.");
|
||
return;
|
||
}
|
||
|
||
if (!this.revertVideoModal) {
|
||
const initialized = await this.initRevertVideoModal();
|
||
if (!initialized || !this.revertVideoModal) {
|
||
this.showError("Revert modal is not available right now.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
const history = await nostrClient.hydrateVideoHistory(video);
|
||
|
||
this.populateRevertVideoModal(video, history);
|
||
this.showRevertVideoModal();
|
||
} catch (err) {
|
||
console.error("Failed to revert video:", err);
|
||
this.showError("Failed to load revision history. Please try again.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle "Delete Video" from gear menu.
|
||
*/
|
||
async handleFullDeleteVideo(target) {
|
||
try {
|
||
const normalizedTarget = this.normalizeActionTarget(target);
|
||
const all = await nostrClient.fetchVideos();
|
||
const video = await this.resolveVideoActionTarget({
|
||
...normalizedTarget,
|
||
preloadedList: all,
|
||
});
|
||
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to delete videos.");
|
||
return;
|
||
}
|
||
const userPubkey = (this.pubkey || "").toLowerCase();
|
||
const videoPubkey = (video?.pubkey || "").toLowerCase();
|
||
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
|
||
this.showError("You do not own this video.");
|
||
return;
|
||
}
|
||
// Make sure the user is absolutely sure:
|
||
if (
|
||
!confirm(
|
||
`Delete ALL versions of "${video.title}"? This action is permanent.`
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// We assume video.videoRootId is not empty, or fallback to video.id if needed
|
||
const rootId = video.videoRootId || video.id;
|
||
|
||
await nostrClient.deleteAllVersions(rootId, this.pubkey);
|
||
|
||
// Reload
|
||
await this.loadVideos();
|
||
this.showSuccess("All versions deleted successfully!");
|
||
this.forceRefreshAllProfiles();
|
||
} catch (err) {
|
||
console.error("Failed to delete all versions:", err);
|
||
this.showError("Failed to delete all versions. Please try again.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* If there's a ?v= param in the URL, auto-open that video.
|
||
*/
|
||
checkUrlParams() {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const maybeNevent = urlParams.get("v");
|
||
if (!maybeNevent) return; // no link param
|
||
|
||
try {
|
||
const decoded = window.NostrTools.nip19.decode(maybeNevent);
|
||
if (decoded.type === "nevent" && decoded.data.id) {
|
||
const eventId = decoded.data.id;
|
||
// 1) check local map
|
||
let localMatch = this.videosMap.get(eventId);
|
||
if (localMatch) {
|
||
this.playVideoByEventId(eventId);
|
||
} else {
|
||
// 2) fallback => getOldEventById
|
||
this.getOldEventById(eventId)
|
||
.then((video) => {
|
||
if (video) {
|
||
this.playVideoByEventId(eventId);
|
||
} else {
|
||
this.showError("No matching video found for that link.");
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.error("Error fetching older event by ID:", err);
|
||
this.showError("Could not load videos for the share link.");
|
||
});
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Error decoding nevent:", err);
|
||
this.showError("Invalid share link.");
|
||
}
|
||
}
|
||
|
||
async probeUrlWithVideoElement(url, timeoutMs = URL_PROBE_TIMEOUT_MS) {
|
||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||
if (!trimmed || typeof document === "undefined") {
|
||
return { outcome: "error" };
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
const video = document.createElement("video");
|
||
let settled = false;
|
||
let timeoutId = null;
|
||
|
||
const cleanup = () => {
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
video.removeEventListener("loadeddata", handleSuccess);
|
||
video.removeEventListener("canplay", handleSuccess);
|
||
video.removeEventListener("error", handleError);
|
||
try {
|
||
video.pause();
|
||
} catch (err) {
|
||
// ignore pause failures (e.g. if never played)
|
||
}
|
||
try {
|
||
video.removeAttribute("src");
|
||
video.load();
|
||
} catch (err) {
|
||
// ignore cleanup failures
|
||
}
|
||
};
|
||
|
||
const settle = (result) => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
cleanup();
|
||
resolve(result);
|
||
};
|
||
|
||
const handleSuccess = () => {
|
||
settle({ outcome: "ok" });
|
||
};
|
||
|
||
const handleError = () => {
|
||
settle({ outcome: "error" });
|
||
};
|
||
|
||
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
||
timeoutId = setTimeout(() => {
|
||
settle({ outcome: "timeout" });
|
||
}, timeoutMs);
|
||
}
|
||
|
||
try {
|
||
video.preload = "metadata";
|
||
video.muted = true;
|
||
video.playsInline = true;
|
||
video.addEventListener("loadeddata", handleSuccess, { once: true });
|
||
video.addEventListener("canplay", handleSuccess, { once: true });
|
||
video.addEventListener("error", handleError, { once: true });
|
||
video.src = trimmed;
|
||
video.load();
|
||
} catch (err) {
|
||
settle({ outcome: "error", error: err });
|
||
}
|
||
});
|
||
}
|
||
|
||
async probeUrl(url, options = {}) {
|
||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||
if (!trimmed) {
|
||
return { outcome: "invalid" };
|
||
}
|
||
|
||
const confirmPlayable = options?.confirmPlayable === true;
|
||
|
||
const confirmWithVideoElement = async () => {
|
||
if (!confirmPlayable) {
|
||
return null;
|
||
}
|
||
|
||
const initialTimeout =
|
||
Number.isFinite(options?.videoProbeTimeoutMs) &&
|
||
options.videoProbeTimeoutMs > 0
|
||
? options.videoProbeTimeoutMs
|
||
: URL_PROBE_TIMEOUT_MS;
|
||
|
||
const attemptWithTimeout = async (timeoutMs) => {
|
||
try {
|
||
const result = await this.probeUrlWithVideoElement(trimmed, timeoutMs);
|
||
if (result && result.outcome) {
|
||
return result;
|
||
}
|
||
} catch (err) {
|
||
console.warn(
|
||
`[probeUrl] Video element probe threw for ${trimmed}:`,
|
||
err
|
||
);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
let result = await attemptWithTimeout(initialTimeout);
|
||
|
||
if (
|
||
result &&
|
||
result.outcome === "timeout" &&
|
||
Number.isFinite(URL_PROBE_TIMEOUT_RETRY_MS) &&
|
||
URL_PROBE_TIMEOUT_RETRY_MS > initialTimeout
|
||
) {
|
||
const retryResult = await attemptWithTimeout(URL_PROBE_TIMEOUT_RETRY_MS);
|
||
if (retryResult) {
|
||
result = { ...retryResult, retriedAfterTimeout: true };
|
||
}
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
const supportsAbort = typeof AbortController !== "undefined";
|
||
const controller = supportsAbort ? new AbortController() : null;
|
||
let timeoutId = null;
|
||
|
||
const racers = [
|
||
fetch(trimmed, {
|
||
method: "HEAD",
|
||
mode: "no-cors",
|
||
cache: "no-store",
|
||
signal: controller ? controller.signal : undefined,
|
||
}),
|
||
];
|
||
|
||
if (Number.isFinite(URL_PROBE_TIMEOUT_MS) && URL_PROBE_TIMEOUT_MS > 0) {
|
||
racers.push(
|
||
new Promise((resolve) => {
|
||
timeoutId = setTimeout(() => {
|
||
if (controller) {
|
||
try {
|
||
controller.abort();
|
||
} catch (err) {
|
||
// ignore abort errors
|
||
}
|
||
}
|
||
resolve({ outcome: "timeout" });
|
||
}, URL_PROBE_TIMEOUT_MS);
|
||
})
|
||
);
|
||
}
|
||
|
||
let responseOrTimeout;
|
||
try {
|
||
responseOrTimeout = await Promise.race(racers);
|
||
} catch (err) {
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
console.warn(`[probeUrl] HEAD request failed for ${trimmed}:`, err);
|
||
const fallback = await confirmWithVideoElement();
|
||
if (fallback) {
|
||
return fallback;
|
||
}
|
||
return confirmPlayable
|
||
? { outcome: "error", error: err }
|
||
: { outcome: "unknown", error: err };
|
||
}
|
||
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
|
||
if (responseOrTimeout && responseOrTimeout.outcome === "timeout") {
|
||
const fallback = await confirmWithVideoElement();
|
||
if (fallback) {
|
||
return fallback;
|
||
}
|
||
return confirmPlayable ? { outcome: "timeout" } : { outcome: "unknown" };
|
||
}
|
||
|
||
const response = responseOrTimeout;
|
||
if (!response) {
|
||
return { outcome: "error" };
|
||
}
|
||
|
||
if (response.type === "opaque") {
|
||
const fallback = await confirmWithVideoElement();
|
||
if (fallback) {
|
||
return fallback;
|
||
}
|
||
return { outcome: confirmPlayable ? "opaque" : "unknown" };
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const fallback = await confirmWithVideoElement();
|
||
if (fallback) {
|
||
return fallback;
|
||
}
|
||
return {
|
||
outcome: "bad",
|
||
status: response.status,
|
||
};
|
||
}
|
||
|
||
const playbackCheck = await confirmWithVideoElement();
|
||
if (playbackCheck) {
|
||
if (playbackCheck.outcome === "ok") {
|
||
return {
|
||
...playbackCheck,
|
||
status: response.status,
|
||
};
|
||
}
|
||
return playbackCheck;
|
||
}
|
||
|
||
return {
|
||
outcome: "ok",
|
||
status: response.status,
|
||
};
|
||
}
|
||
|
||
async playHttp(videoEl, url) {
|
||
const target = videoEl || this.modalVideo;
|
||
if (!target) {
|
||
return false;
|
||
}
|
||
|
||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||
if (!sanitizedUrl) {
|
||
return false;
|
||
}
|
||
|
||
target.src = sanitizedUrl;
|
||
|
||
try {
|
||
await target.play();
|
||
return true;
|
||
} catch (err) {
|
||
console.warn("[playHttp] Direct URL playback failed:", err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async playViaWebTorrent(
|
||
magnet,
|
||
{ fallbackMagnet = "", urlList = [] } = {}
|
||
) {
|
||
const sanitizedUrlList = Array.isArray(urlList)
|
||
? urlList
|
||
.map((entry) =>
|
||
typeof entry === "string" ? entry.trim() : ""
|
||
)
|
||
.filter((entry) => /^https?:\/\//i.test(entry))
|
||
: [];
|
||
|
||
const attemptStream = async (candidate) => {
|
||
const trimmedCandidate =
|
||
typeof candidate === "string" ? candidate.trim() : "";
|
||
if (!trimmedCandidate) {
|
||
throw new Error("No magnet URI provided for torrent playback.");
|
||
}
|
||
if (!isValidMagnetUri(trimmedCandidate)) {
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent = UNSUPPORTED_BTITH_MESSAGE;
|
||
}
|
||
throw new Error(UNSUPPORTED_BTITH_MESSAGE);
|
||
}
|
||
if (!this.modalVideo) {
|
||
throw new Error(
|
||
"No modal video element available for torrent playback."
|
||
);
|
||
}
|
||
|
||
const timestamp = Date.now().toString();
|
||
const [magnetPrefix, magnetQuery = ""] = trimmedCandidate.split("?", 2);
|
||
let normalizedMagnet = magnetPrefix;
|
||
let queryParts = magnetQuery
|
||
.split("&")
|
||
.map((part) => part.trim())
|
||
.filter((part) => part && !/^ts=\d+$/.test(part));
|
||
|
||
if (queryParts.length) {
|
||
normalizedMagnet = `${magnetPrefix}?${queryParts.join("&")}`;
|
||
}
|
||
|
||
const separator = normalizedMagnet.includes("?") ? "&" : "?";
|
||
const cacheBustedMagnet = `${normalizedMagnet}${separator}ts=${timestamp}`;
|
||
|
||
await torrentClient.cleanup();
|
||
this.resetTorrentStats();
|
||
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent = "Streaming via WebTorrent";
|
||
}
|
||
|
||
const torrentInstance = await torrentClient.streamVideo(
|
||
cacheBustedMagnet,
|
||
this.modalVideo,
|
||
{ urlList: sanitizedUrlList }
|
||
);
|
||
if (torrentInstance && torrentInstance.ready) {
|
||
// Some browsers delay `playing` events for MediaSource-backed torrents.
|
||
// Clearing the poster here prevents the historic "GIF stuck over the
|
||
// video" regression when WebTorrent is already feeding data.
|
||
this.forceRemoveModalPoster("webtorrent-ready");
|
||
}
|
||
this.startTorrentStatusMirrors(torrentInstance);
|
||
return torrentInstance;
|
||
};
|
||
|
||
const primaryTrimmed =
|
||
typeof magnet === "string" ? magnet.trim() : "";
|
||
const fallbackTrimmed =
|
||
typeof fallbackMagnet === "string" ? fallbackMagnet.trim() : "";
|
||
const hasFallback =
|
||
!!fallbackTrimmed && fallbackTrimmed !== primaryTrimmed;
|
||
|
||
try {
|
||
return await attemptStream(primaryTrimmed);
|
||
} catch (primaryError) {
|
||
if (!hasFallback) {
|
||
throw primaryError;
|
||
}
|
||
this.log(
|
||
`[playViaWebTorrent] Normalized magnet failed: ${primaryError.message}`
|
||
);
|
||
this.log(
|
||
"[playViaWebTorrent] Primary magnet failed, retrying original string."
|
||
);
|
||
try {
|
||
return await attemptStream(fallbackTrimmed);
|
||
} catch (fallbackError) {
|
||
throw fallbackError;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Unified playback helper that prefers HTTP URL sources
|
||
* and falls back to WebTorrent when needed.
|
||
*/
|
||
async playVideoWithFallback({ url = "", magnet = "" } = {}) {
|
||
// When the modal closes we run an asynchronous cleanup that tears down the
|
||
// previous MediaSource attachment. If the user immediately selects another
|
||
// video we must wait for that teardown to finish or the stale cleanup will
|
||
// wipe the freshly attached `src`/`srcObject`, leaving playback stuck on a
|
||
// blank frame. Guard every playback entry point with waitForCleanup() so the
|
||
// race never reappears.
|
||
await this.waitForCleanup();
|
||
this.cancelPendingViewLogging();
|
||
|
||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||
|
||
const playbackConfig = deriveTorrentPlaybackConfig({
|
||
magnet: trimmedMagnet,
|
||
url: sanitizedUrl,
|
||
logger: (message) => this.log(message),
|
||
});
|
||
|
||
const magnetIsUsable = isValidMagnetUri(playbackConfig.magnet);
|
||
const magnetForPlayback = magnetIsUsable ? playbackConfig.magnet : "";
|
||
const fallbackMagnet = magnetIsUsable
|
||
? playbackConfig.fallbackMagnet
|
||
: "";
|
||
const magnetProvided = playbackConfig.provided;
|
||
|
||
this.log(
|
||
`[playVideoWithFallback] Session start urlProvided=${!!sanitizedUrl} magnetProvided=${magnetProvided} magnetUsable=${!!magnetForPlayback}`
|
||
);
|
||
|
||
if (this.currentVideo) {
|
||
this.currentVideo.magnet = magnetForPlayback;
|
||
this.currentVideo.normalizedMagnet = magnetForPlayback;
|
||
this.currentVideo.normalizedMagnetFallback = fallbackMagnet;
|
||
if (playbackConfig.infoHash && !this.currentVideo.legacyInfoHash) {
|
||
this.currentVideo.legacyInfoHash = playbackConfig.infoHash;
|
||
}
|
||
this.currentVideo.torrentSupported = !!magnetForPlayback;
|
||
}
|
||
this.currentMagnetUri = magnetForPlayback || null;
|
||
this.setCopyMagnetState(!!magnetForPlayback);
|
||
|
||
let ensureDebugListenersRemoved = () => {};
|
||
|
||
try {
|
||
if (!this.modalVideo) {
|
||
throw new Error("Video element is not ready for playback.");
|
||
}
|
||
|
||
if (typeof this.modalPosterCleanup === "function") {
|
||
try {
|
||
this.modalPosterCleanup();
|
||
} catch (err) {
|
||
console.warn("[playVideoWithFallback] modal poster cleanup threw:", err);
|
||
} finally {
|
||
this.modalPosterCleanup = null;
|
||
}
|
||
}
|
||
|
||
const refreshedModal = this.teardownVideoElement(this.modalVideo, {
|
||
replaceNode: true,
|
||
});
|
||
if (refreshedModal) {
|
||
this.modalVideo = refreshedModal;
|
||
}
|
||
|
||
this.showModalWithPoster();
|
||
const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL";
|
||
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent = "Preparing video...";
|
||
}
|
||
|
||
this.clearActiveIntervals();
|
||
this.prepareModalVideoForPlayback();
|
||
|
||
await torrentClient.cleanup();
|
||
|
||
const activeVideoEl = this.teardownVideoElement(this.modalVideo);
|
||
if (activeVideoEl) {
|
||
this.modalVideo = activeVideoEl;
|
||
this.log(
|
||
`[playVideoWithFallback] Modal video prepared (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
|
||
);
|
||
}
|
||
|
||
if (activeVideoEl) {
|
||
const debugEvents = [
|
||
"loadedmetadata",
|
||
"loadeddata",
|
||
"canplay",
|
||
"canplaythrough",
|
||
"play",
|
||
"playing",
|
||
"pause",
|
||
"stalled",
|
||
"suspend",
|
||
"waiting",
|
||
"ended",
|
||
"error",
|
||
];
|
||
const debugHandlers = [];
|
||
ensureDebugListenersRemoved = () => {
|
||
if (!debugHandlers.length) {
|
||
return;
|
||
}
|
||
for (const [eventName, handler] of debugHandlers) {
|
||
activeVideoEl.removeEventListener(eventName, handler);
|
||
}
|
||
debugHandlers.length = 0;
|
||
ensureDebugListenersRemoved = () => {};
|
||
};
|
||
for (const eventName of debugEvents) {
|
||
const handler = () => {
|
||
const { readyState, networkState, currentTime, paused, error } =
|
||
activeVideoEl;
|
||
let suffix = `readyState=${readyState} networkState=${networkState} currentTime=${Number.isFinite(currentTime) ? currentTime.toFixed(2) : currentTime} paused=${paused}`;
|
||
if (eventName === "error" && error) {
|
||
const errorMessage = error.message || "";
|
||
suffix += ` code=${error.code || ""} message=${errorMessage}`;
|
||
}
|
||
this.log(
|
||
`[playVideoWithFallback] <video> event ${eventName}; ${suffix}`
|
||
);
|
||
};
|
||
activeVideoEl.addEventListener(eventName, handler);
|
||
debugHandlers.push([eventName, handler]);
|
||
}
|
||
} else {
|
||
ensureDebugListenersRemoved = () => {};
|
||
}
|
||
|
||
this.resetTorrentStats();
|
||
this.playSource = null;
|
||
this.cleanupUrlPlaybackWatchdog();
|
||
this.preparePlaybackViewLogging(activeVideoEl);
|
||
|
||
if (magnetForPlayback) {
|
||
const label = playbackConfig.didMutate
|
||
? "[playVideoWithFallback] Using normalized magnet URI:"
|
||
: "[playVideoWithFallback] Using magnet URI:";
|
||
this.log(`${label} ${magnetForPlayback}`);
|
||
}
|
||
|
||
const httpsUrl =
|
||
sanitizedUrl && /^https:\/\//i.test(sanitizedUrl) ? sanitizedUrl : "";
|
||
const webSeedCandidates = httpsUrl ? [httpsUrl] : [];
|
||
let cleanupHostedUrlStatusListeners = () => {};
|
||
const cleanupDebugListeners = () => {
|
||
ensureDebugListenersRemoved();
|
||
};
|
||
|
||
let fallbackStarted = false;
|
||
const startTorrentFallback = async (reason) => {
|
||
if (fallbackStarted) {
|
||
this.log(
|
||
`[playVideoWithFallback] Duplicate fallback request ignored (${reason}).`
|
||
);
|
||
return null;
|
||
}
|
||
fallbackStarted = true;
|
||
this.cleanupUrlPlaybackWatchdog();
|
||
cleanupHostedUrlStatusListeners();
|
||
cleanupDebugListeners();
|
||
|
||
if (activeVideoEl) {
|
||
try {
|
||
activeVideoEl.pause();
|
||
} catch (err) {
|
||
this.log(
|
||
"[playVideoWithFallback] Ignoring pause error before torrent fallback:",
|
||
err
|
||
);
|
||
}
|
||
try {
|
||
activeVideoEl.removeAttribute("src");
|
||
} catch (err) {
|
||
// removeAttribute throws in old browsers when the attribute does not exist
|
||
}
|
||
activeVideoEl.src = "";
|
||
activeVideoEl.srcObject = null;
|
||
try {
|
||
activeVideoEl.load();
|
||
} catch (err) {
|
||
this.log(
|
||
"[playVideoWithFallback] Ignoring load error before torrent fallback:",
|
||
err
|
||
);
|
||
}
|
||
}
|
||
if (!magnetForPlayback) {
|
||
const message =
|
||
"Hosted playback failed and no magnet fallback is available.";
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent = message;
|
||
}
|
||
this.playSource = null;
|
||
throw new Error(message);
|
||
}
|
||
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent = "Switching to WebTorrent...";
|
||
}
|
||
this.log(
|
||
`[playVideoWithFallback] Falling back to WebTorrent (${reason}).`
|
||
);
|
||
this.log(
|
||
`[playVideoWithFallback] startTorrentFallback invoked with magnet=${!!magnetForPlayback} readyState=${
|
||
activeVideoEl ? activeVideoEl.readyState : "n/a"
|
||
} networkState=${
|
||
activeVideoEl ? activeVideoEl.networkState : "n/a"
|
||
}`
|
||
);
|
||
console.debug("[WT] add magnet =", magnetForPlayback);
|
||
const torrentInstance = await this.playViaWebTorrent(
|
||
magnetForPlayback,
|
||
{
|
||
fallbackMagnet,
|
||
urlList: webSeedCandidates,
|
||
}
|
||
);
|
||
this.playSource = "torrent";
|
||
this.autoplayModalVideo();
|
||
return torrentInstance;
|
||
};
|
||
|
||
if (URL_FIRST_ENABLED && httpsUrl) {
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent = "Checking hosted URL...";
|
||
}
|
||
this.log(
|
||
`[playVideoWithFallback] Probing hosted URL ${httpsUrl} (readyState=${activeVideoEl?.readyState} networkState=${activeVideoEl?.networkState}).`
|
||
);
|
||
let hostedStatusResolved = false;
|
||
const hostedStatusHandlers = [];
|
||
const addHostedStatusListener = (eventName, handler, options) => {
|
||
if (!activeVideoEl) {
|
||
return;
|
||
}
|
||
activeVideoEl.addEventListener(eventName, handler, options);
|
||
hostedStatusHandlers.push([eventName, handler, options]);
|
||
};
|
||
const markHostedUrlAsLive = () => {
|
||
if (hostedStatusResolved) {
|
||
return;
|
||
}
|
||
hostedStatusResolved = true;
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent = HOSTED_URL_SUCCESS_MESSAGE;
|
||
}
|
||
cleanupHostedUrlStatusListeners();
|
||
};
|
||
const maybeMarkHostedUrl = () => {
|
||
if (
|
||
hostedStatusResolved ||
|
||
!activeVideoEl ||
|
||
activeVideoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||
) {
|
||
return;
|
||
}
|
||
if (activeVideoEl.currentTime > 0 || !activeVideoEl.paused) {
|
||
markHostedUrlAsLive();
|
||
}
|
||
};
|
||
cleanupHostedUrlStatusListeners = () => {
|
||
if (!hostedStatusHandlers.length || !activeVideoEl) {
|
||
hostedStatusHandlers.length = 0;
|
||
cleanupHostedUrlStatusListeners = () => {};
|
||
return;
|
||
}
|
||
for (const [eventName, handler, options] of hostedStatusHandlers) {
|
||
activeVideoEl.removeEventListener(eventName, handler, options);
|
||
}
|
||
hostedStatusHandlers.length = 0;
|
||
cleanupHostedUrlStatusListeners = () => {};
|
||
};
|
||
addHostedStatusListener("playing", markHostedUrlAsLive, { once: true });
|
||
addHostedStatusListener("loadeddata", maybeMarkHostedUrl);
|
||
addHostedStatusListener("canplay", maybeMarkHostedUrl);
|
||
addHostedStatusListener("error", () => {
|
||
cleanupHostedUrlStatusListeners();
|
||
cleanupDebugListeners();
|
||
}, { once: true });
|
||
const probeResult = await this.probeUrl(httpsUrl);
|
||
const probeOutcome = probeResult?.outcome || "error";
|
||
const shouldAttemptHosted =
|
||
probeOutcome !== "bad" && probeOutcome !== "error";
|
||
|
||
this.log(
|
||
`[playVideoWithFallback] Hosted URL probe outcome=${probeOutcome} shouldAttemptHosted=${shouldAttemptHosted}`
|
||
);
|
||
|
||
if (shouldAttemptHosted) {
|
||
let outcomeResolved = false;
|
||
let outcomeResolver = () => {};
|
||
let autoplayBlocked = false;
|
||
|
||
const playbackOutcomePromise = new Promise((resolve) => {
|
||
outcomeResolver = (value) => {
|
||
if (outcomeResolved) {
|
||
return;
|
||
}
|
||
outcomeResolved = true;
|
||
resolve(value);
|
||
};
|
||
});
|
||
|
||
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
|
||
this.registerUrlPlaybackWatchdogs(activeVideoEl, {
|
||
stallMs,
|
||
onSuccess: () => outcomeResolver({ status: "success" }),
|
||
onFallback: (reason) => {
|
||
if (autoplayBlocked && reason === "stall") {
|
||
this.log(
|
||
"[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back."
|
||
);
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent =
|
||
"Press play to start the hosted video.";
|
||
}
|
||
attachWatchdogs({ stallMs: 0 });
|
||
return;
|
||
}
|
||
|
||
outcomeResolver({ status: "fallback", reason });
|
||
},
|
||
});
|
||
};
|
||
|
||
attachWatchdogs({ stallMs: 8000 });
|
||
|
||
const handleFatalPlaybackError = (err) => {
|
||
this.log(
|
||
"[playVideoWithFallback] Direct URL playback threw:",
|
||
err
|
||
);
|
||
outcomeResolver({ status: "fallback", reason: "play-error" });
|
||
};
|
||
|
||
try {
|
||
activeVideoEl.src = httpsUrl;
|
||
const playPromise = activeVideoEl.play();
|
||
if (playPromise && typeof playPromise.catch === "function") {
|
||
playPromise.catch((err) => {
|
||
if (err?.name === "NotAllowedError") {
|
||
autoplayBlocked = true;
|
||
this.log(
|
||
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
|
||
err
|
||
);
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent =
|
||
"Press play to start the hosted video.";
|
||
}
|
||
this.cleanupUrlPlaybackWatchdog();
|
||
attachWatchdogs({ stallMs: 0 });
|
||
const restoreOnPlay = () => {
|
||
activeVideoEl.removeEventListener("play", restoreOnPlay);
|
||
this.cleanupUrlPlaybackWatchdog();
|
||
autoplayBlocked = false;
|
||
attachWatchdogs({ stallMs: 8000 });
|
||
};
|
||
activeVideoEl.addEventListener("play", restoreOnPlay, {
|
||
once: true,
|
||
});
|
||
return;
|
||
}
|
||
handleFatalPlaybackError(err);
|
||
});
|
||
}
|
||
} catch (err) {
|
||
handleFatalPlaybackError(err);
|
||
}
|
||
|
||
const playbackOutcome = await playbackOutcomePromise;
|
||
if (playbackOutcome?.status === "success") {
|
||
this.forceRemoveModalPoster("http-success");
|
||
this.playSource = "url";
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent = HOSTED_URL_SUCCESS_MESSAGE;
|
||
}
|
||
cleanupHostedUrlStatusListeners();
|
||
cleanupDebugListeners();
|
||
return;
|
||
}
|
||
|
||
const fallbackReason = playbackOutcome?.reason || "watchdog-triggered";
|
||
await startTorrentFallback(fallbackReason);
|
||
return;
|
||
}
|
||
|
||
this.log(
|
||
`[playVideoWithFallback] Hosted URL probe reported "${probeOutcome}"; deferring to WebTorrent.`
|
||
);
|
||
cleanupHostedUrlStatusListeners();
|
||
cleanupDebugListeners();
|
||
}
|
||
|
||
if (magnetForPlayback) {
|
||
await startTorrentFallback("magnet-primary");
|
||
return;
|
||
}
|
||
|
||
const message = magnetProvided && !magnetForPlayback
|
||
? UNSUPPORTED_BTITH_MESSAGE
|
||
: "No playable source found.";
|
||
if (this.modalStatus) {
|
||
this.modalStatus.textContent = message;
|
||
}
|
||
this.playSource = null;
|
||
cleanupDebugListeners();
|
||
this.showError(message);
|
||
} catch (error) {
|
||
ensureDebugListenersRemoved();
|
||
this.log("Error in playVideoWithFallback:", error);
|
||
this.showError(`Playback error: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async playVideoByEventId(eventId) {
|
||
if (!eventId) {
|
||
this.showError("No video identifier provided.");
|
||
return;
|
||
}
|
||
|
||
this.currentVideoPointer = null;
|
||
this.currentVideoPointerKey = null;
|
||
|
||
if (this.blacklistedEventIds.has(eventId)) {
|
||
this.showError("This content has been removed or is not allowed.");
|
||
return;
|
||
}
|
||
|
||
let video = this.videosMap.get(eventId);
|
||
if (!video) {
|
||
video = await this.getOldEventById(eventId);
|
||
}
|
||
if (!video) {
|
||
this.showError("Video not found or has been removed.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await accessControl.ensureReady();
|
||
} catch (error) {
|
||
console.warn("Failed to ensure admin lists were loaded before playback:", error);
|
||
}
|
||
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||
if (!accessControl.canAccess(authorNpub)) {
|
||
if (accessControl.isBlacklisted(authorNpub)) {
|
||
this.showError("This content has been removed or is not allowed.");
|
||
} else if (accessControl.whitelistMode()) {
|
||
this.showError("This content is not from a whitelisted author.");
|
||
} else {
|
||
this.showError("This content has been removed or is not allowed.");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (
|
||
video.isPrivate &&
|
||
video.pubkey === this.pubkey &&
|
||
!video.alreadyDecrypted
|
||
) {
|
||
video.magnet = fakeDecrypt(video.magnet);
|
||
video.alreadyDecrypted = true;
|
||
}
|
||
|
||
const trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
|
||
const rawMagnet =
|
||
typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||
const legacyInfoHash =
|
||
typeof video.infoHash === "string" ? video.infoHash.trim().toLowerCase() : "";
|
||
const magnetCandidate = rawMagnet || legacyInfoHash;
|
||
const decodedMagnetCandidate = safeDecodeMagnet(magnetCandidate);
|
||
const usableMagnetCandidate = decodedMagnetCandidate || magnetCandidate;
|
||
const magnetSupported = isValidMagnetUri(usableMagnetCandidate);
|
||
const sanitizedMagnet = magnetSupported ? usableMagnetCandidate : "";
|
||
|
||
trackVideoView({
|
||
videoId: video.id || eventId,
|
||
title: video.title || "Untitled",
|
||
source: "event",
|
||
hasMagnet: !!sanitizedMagnet,
|
||
hasUrl: !!trimmedUrl,
|
||
});
|
||
|
||
this.currentVideo = {
|
||
...video,
|
||
url: trimmedUrl,
|
||
magnet: sanitizedMagnet,
|
||
originalMagnet: magnetCandidate,
|
||
torrentSupported: magnetSupported,
|
||
legacyInfoHash: video.legacyInfoHash || legacyInfoHash,
|
||
lightningAddress: null,
|
||
};
|
||
|
||
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
|
||
const normalizedPubkey =
|
||
typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||
const primaryPointer =
|
||
dTagValue && normalizedPubkey
|
||
? [
|
||
"a",
|
||
`${
|
||
typeof video.kind === "number" && Number.isFinite(video.kind)
|
||
? video.kind
|
||
: 30078
|
||
}:${normalizedPubkey}:${dTagValue}`,
|
||
]
|
||
: null;
|
||
const fallbackId =
|
||
typeof (video.id || eventId) === "string"
|
||
? (video.id || eventId).trim()
|
||
: "";
|
||
const fallbackPointer =
|
||
!primaryPointer && fallbackId
|
||
? ["e", fallbackId]
|
||
: null;
|
||
|
||
const resolvedPointer = primaryPointer || fallbackPointer;
|
||
const resolvedPointerKey = pointerArrayToKey(resolvedPointer);
|
||
|
||
this.currentVideoPointer = resolvedPointer && resolvedPointerKey
|
||
? resolvedPointer
|
||
: null;
|
||
this.currentVideoPointerKey = this.currentVideoPointer
|
||
? resolvedPointerKey
|
||
: null;
|
||
|
||
if (this.currentVideo) {
|
||
this.currentVideo.pointer = this.currentVideoPointer;
|
||
this.currentVideo.pointerKey = this.currentVideoPointerKey;
|
||
}
|
||
|
||
this.subscribeModalViewCount(
|
||
this.currentVideoPointer,
|
||
this.currentVideoPointerKey
|
||
);
|
||
|
||
this.syncModalMoreMenuData();
|
||
|
||
this.currentMagnetUri = sanitizedMagnet || null;
|
||
|
||
this.setCopyMagnetState(!!sanitizedMagnet);
|
||
this.setShareButtonState(true);
|
||
|
||
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
|
||
const pushUrl =
|
||
this.buildShareUrlFromNevent(nevent) ||
|
||
`${this.getShareUrlBase() || window.location.pathname}?v=${encodeURIComponent(
|
||
nevent
|
||
)}`;
|
||
window.history.pushState({}, "", pushUrl);
|
||
|
||
this.setModalZapVisibility(false);
|
||
let lightningAddress = "";
|
||
let creatorProfile = {
|
||
name: "Unknown",
|
||
picture: `https://robohash.org/${video.pubkey}`,
|
||
};
|
||
try {
|
||
const userEvents = await nostrClient.pool.list(nostrClient.relays, [
|
||
{ kinds: [0], authors: [video.pubkey], limit: 1 },
|
||
]);
|
||
if (userEvents.length > 0 && userEvents[0]?.content) {
|
||
const data = JSON.parse(userEvents[0].content);
|
||
lightningAddress = (data.lud16 || data.lud06 || "").trim();
|
||
creatorProfile = {
|
||
name: data.name || data.display_name || "Unknown",
|
||
picture: data.picture || `https://robohash.org/${video.pubkey}`,
|
||
};
|
||
}
|
||
} catch (error) {
|
||
this.log("Error fetching creator profile:", error);
|
||
}
|
||
|
||
this.setModalZapVisibility(!!lightningAddress);
|
||
if (this.currentVideo) {
|
||
this.currentVideo.lightningAddress = lightningAddress || null;
|
||
}
|
||
|
||
const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||
if (this.videoTitle) {
|
||
this.videoTitle.textContent = video.title || "Untitled";
|
||
}
|
||
if (this.videoDescription) {
|
||
this.videoDescription.textContent =
|
||
video.description || "No description available.";
|
||
}
|
||
if (this.videoTimestamp) {
|
||
this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at);
|
||
}
|
||
if (this.creatorName) {
|
||
this.creatorName.textContent = creatorProfile.name;
|
||
}
|
||
if (this.creatorNpub) {
|
||
this.creatorNpub.textContent = `${creatorNpub.slice(0, 8)}...${creatorNpub.slice(
|
||
-4
|
||
)}`;
|
||
}
|
||
if (this.creatorAvatar) {
|
||
this.creatorAvatar.src = creatorProfile.picture;
|
||
this.creatorAvatar.alt = creatorProfile.name;
|
||
}
|
||
|
||
await this.playVideoWithFallback({
|
||
url: trimmedUrl,
|
||
magnet: usableMagnetCandidate,
|
||
});
|
||
}
|
||
|
||
async playVideoWithoutEvent({
|
||
url = "",
|
||
magnet = "",
|
||
title = "Untitled",
|
||
description = "",
|
||
} = {}) {
|
||
this.currentVideoPointer = null;
|
||
this.currentVideoPointerKey = null;
|
||
this.subscribeModalViewCount(null, null);
|
||
|
||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||
const decodedMagnet = safeDecodeMagnet(trimmedMagnet);
|
||
const usableMagnet = decodedMagnet || trimmedMagnet;
|
||
const magnetSupported = isValidMagnetUri(usableMagnet);
|
||
const sanitizedMagnet = magnetSupported ? usableMagnet : "";
|
||
|
||
this.setModalZapVisibility(false);
|
||
|
||
trackVideoView({
|
||
videoId:
|
||
typeof title === "string" && title.trim().length > 0
|
||
? `direct:${title.trim()}`
|
||
: "direct-playback",
|
||
title,
|
||
source: "direct",
|
||
hasMagnet: !!sanitizedMagnet,
|
||
hasUrl: !!sanitizedUrl,
|
||
});
|
||
|
||
if (!sanitizedUrl && !sanitizedMagnet) {
|
||
const message = trimmedMagnet && !magnetSupported
|
||
? UNSUPPORTED_BTITH_MESSAGE
|
||
: "This video has no playable source.";
|
||
this.showError(message);
|
||
return;
|
||
}
|
||
|
||
this.currentVideo = {
|
||
id: null,
|
||
title,
|
||
description,
|
||
url: sanitizedUrl,
|
||
magnet: sanitizedMagnet,
|
||
originalMagnet: trimmedMagnet,
|
||
torrentSupported: magnetSupported,
|
||
lightningAddress: null,
|
||
pointer: null,
|
||
pointerKey: null,
|
||
};
|
||
|
||
this.syncModalMoreMenuData();
|
||
|
||
this.currentMagnetUri = sanitizedMagnet || null;
|
||
|
||
this.setCopyMagnetState(!!sanitizedMagnet);
|
||
this.setShareButtonState(false);
|
||
|
||
if (this.videoTitle) {
|
||
this.videoTitle.textContent = title || "Untitled";
|
||
}
|
||
if (this.videoDescription) {
|
||
this.videoDescription.textContent =
|
||
description || "No description available.";
|
||
}
|
||
if (this.videoTimestamp) {
|
||
this.videoTimestamp.textContent = "";
|
||
}
|
||
if (this.creatorName) {
|
||
this.creatorName.textContent = "Unknown";
|
||
}
|
||
if (this.creatorNpub) {
|
||
this.creatorNpub.textContent = "";
|
||
}
|
||
if (this.creatorAvatar) {
|
||
this.creatorAvatar.src = "assets/svg/default-profile.svg";
|
||
this.creatorAvatar.alt = "Unknown";
|
||
}
|
||
|
||
const urlObj = new URL(window.location.href);
|
||
urlObj.searchParams.delete("v");
|
||
const cleaned = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
|
||
window.history.replaceState({}, "", cleaned);
|
||
|
||
await this.playVideoWithFallback({
|
||
url: sanitizedUrl,
|
||
magnet: usableMagnet,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Simple helper to safely encode an npub.
|
||
*/
|
||
safeEncodeNpub(pubkey) {
|
||
if (typeof pubkey !== "string") {
|
||
return null;
|
||
}
|
||
|
||
const trimmed = pubkey.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
if (trimmed.startsWith("npub1")) {
|
||
return trimmed;
|
||
}
|
||
|
||
try {
|
||
return window.NostrTools.nip19.npubEncode(trimmed);
|
||
} catch (err) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
safeDecodeNpub(npub) {
|
||
if (typeof npub !== "string") {
|
||
return null;
|
||
}
|
||
|
||
const trimmed = npub.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||
if (decoded.type === "npub" && typeof decoded.data === "string") {
|
||
return decoded.data;
|
||
}
|
||
} catch (err) {
|
||
return null;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
normalizeHexPubkey(pubkey) {
|
||
if (typeof pubkey !== "string") {
|
||
return null;
|
||
}
|
||
|
||
const trimmed = pubkey.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
if (HEX64_REGEX.test(trimmed)) {
|
||
return trimmed.toLowerCase();
|
||
}
|
||
|
||
if (trimmed.startsWith("npub1")) {
|
||
const decoded = this.safeDecodeNpub(trimmed);
|
||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||
return decoded.toLowerCase();
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Attempts to fetch an older event by its ID if we can't find it in
|
||
* this.videosMap or from a bulk fetch. Uses nostrClient.getEventById.
|
||
*/
|
||
async getOldEventById(eventId) {
|
||
// 1) Already in our local videosMap?
|
||
let video = this.videosMap.get(eventId);
|
||
if (video) {
|
||
return video;
|
||
}
|
||
|
||
// 2) Already in nostrClient.allEvents?
|
||
// (assuming nostrClient.allEvents is a Map of id => video)
|
||
const fromAll = nostrClient.allEvents.get(eventId);
|
||
if (fromAll && !fromAll.deleted) {
|
||
this.videosMap.set(eventId, fromAll);
|
||
return fromAll;
|
||
}
|
||
|
||
// 3) Direct single-event fetch (fewer resources than full fetchVideos)
|
||
const single = await nostrClient.getEventById(eventId);
|
||
if (single && !single.deleted) {
|
||
this.videosMap.set(single.id, single);
|
||
return single;
|
||
}
|
||
|
||
// 4) If you wanted a final fallback, you could do it here:
|
||
// But it's typically better to avoid repeated full fetches
|
||
// console.log("Falling back to full fetchVideos...");
|
||
// const allFetched = await nostrClient.fetchVideos();
|
||
// video = allFetched.find(v => v.id === eventId && !v.deleted);
|
||
// if (video) {
|
||
// this.videosMap.set(video.id, video);
|
||
// return video;
|
||
// }
|
||
|
||
// Not found or was deleted
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Format "time ago" for a given timestamp (in seconds).
|
||
*/
|
||
formatAbsoluteTimestamp(timestamp) {
|
||
if (!Number.isFinite(timestamp)) {
|
||
return "Unknown date";
|
||
}
|
||
|
||
const date = new Date(timestamp * 1000);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return "Unknown date";
|
||
}
|
||
|
||
try {
|
||
return date.toLocaleString(undefined, {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
} catch (err) {
|
||
return date.toISOString();
|
||
}
|
||
}
|
||
|
||
formatTimeAgo(timestamp) {
|
||
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||
const intervals = {
|
||
year: 31536000,
|
||
month: 2592000,
|
||
week: 604800,
|
||
day: 86400,
|
||
hour: 3600,
|
||
minute: 60,
|
||
};
|
||
for (const [unit, secInUnit] of Object.entries(intervals)) {
|
||
const int = Math.floor(seconds / secInUnit);
|
||
if (int >= 1) {
|
||
return `${int} ${unit}${int > 1 ? "s" : ""} ago`;
|
||
}
|
||
}
|
||
return "just now";
|
||
}
|
||
|
||
escapeHTML(unsafe) {
|
||
if (unsafe === null || typeof unsafe === "undefined") {
|
||
return "";
|
||
}
|
||
|
||
return String(unsafe)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
showError(msg) {
|
||
if (!msg) {
|
||
// Remove any content, then hide
|
||
this.errorContainer.textContent = "";
|
||
this.errorContainer.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
// If there's a message, show it
|
||
this.errorContainer.textContent = msg;
|
||
this.errorContainer.classList.remove("hidden");
|
||
|
||
// Optional auto-hide after 5 seconds
|
||
setTimeout(() => {
|
||
this.errorContainer.textContent = "";
|
||
this.errorContainer.classList.add("hidden");
|
||
}, 5000);
|
||
}
|
||
|
||
showStatus(msg) {
|
||
if (!(this.statusContainer instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
if (!msg) {
|
||
if (this.statusMessage instanceof HTMLElement) {
|
||
this.statusMessage.textContent = "";
|
||
}
|
||
this.statusContainer.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
if (this.statusMessage instanceof HTMLElement) {
|
||
this.statusMessage.textContent = msg;
|
||
} else {
|
||
this.statusContainer.textContent = msg;
|
||
}
|
||
this.statusContainer.classList.remove("hidden");
|
||
}
|
||
|
||
showSuccess(msg) {
|
||
if (!msg) {
|
||
this.successContainer.textContent = "";
|
||
this.successContainer.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
this.successContainer.textContent = msg;
|
||
this.successContainer.classList.remove("hidden");
|
||
|
||
setTimeout(() => {
|
||
this.successContainer.textContent = "";
|
||
this.successContainer.classList.add("hidden");
|
||
}, 5000);
|
||
}
|
||
|
||
log(msg) {
|
||
console.log(msg);
|
||
}
|
||
|
||
/**
|
||
* Copies the current video's magnet link to the clipboard.
|
||
*/
|
||
handleCopyMagnet() {
|
||
if (!this.currentVideo || !this.currentVideo.magnet) {
|
||
if (
|
||
this.currentVideo &&
|
||
this.currentVideo.originalMagnet &&
|
||
!this.currentVideo.torrentSupported
|
||
) {
|
||
this.showError(UNSUPPORTED_BTITH_MESSAGE);
|
||
return;
|
||
}
|
||
|
||
this.showError("No magnet link to copy.");
|
||
return;
|
||
}
|
||
try {
|
||
navigator.clipboard.writeText(this.currentVideo.magnet);
|
||
this.showSuccess("Magnet link copied to clipboard!");
|
||
} catch (err) {
|
||
console.error("Failed to copy magnet link:", err);
|
||
this.showError("Could not copy magnet link. Please copy it manually.");
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Given an array of video objects,
|
||
* return only the newest (by created_at) for each videoRootId.
|
||
* If no videoRootId is present, treat the video’s own ID as its root.
|
||
*/
|
||
function dedupeToNewestByRoot(videos) {
|
||
const map = new Map(); // key = rootId, value = newest video for that root
|
||
|
||
for (const vid of videos) {
|
||
// If there's no videoRootId, fall back to vid.id (treat it as its own "root")
|
||
const rootId = vid.videoRootId || vid.id;
|
||
|
||
const existing = map.get(rootId);
|
||
if (!existing || vid.created_at > existing.created_at) {
|
||
map.set(rootId, vid);
|
||
}
|
||
}
|
||
|
||
// Return just the newest from each group
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
export const app = new bitvidApp();
|
||
export const appReady = app.init();
|
||
window.app = app;
|
||
window.appReady = appReady;
|