diff --git a/components/revert-video-modal.html b/components/revert-video-modal.html new file mode 100644 index 00000000..87f2e6d7 --- /dev/null +++ b/components/revert-video-modal.html @@ -0,0 +1,121 @@ + + diff --git a/js/app.js b/js/app.js index 0b7d3990..e7c5bb9b 100644 --- a/js/app.js +++ b/js/app.js @@ -549,6 +549,27 @@ class bitvidApp { 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; @@ -1424,6 +1445,779 @@ class bitvidApp { 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 = + '

No revisions found.

'; + 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 = ` +
+
+

${this.escapeHTML( + entry.title || "Untitled" + )}

+

${this.escapeHTML( + relative + )} • ${this.escapeHTML(absolute)}

+ ${ + meta + ? `

${this.escapeHTML(meta)}

` + : "" + } +
+
+ ${this.escapeHTML(versionLabel)} +
+
+ `; + + 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 = 'None'; + if (url) { + const safeUrl = this.escapeHTML(url); + const displayUrl = this.escapeHTML(truncateMiddle(url, 72)); + urlHtml = `${displayUrl}`; + } + + let magnetHtml = 'None'; + if (displayMagnet) { + const label = this.escapeHTML(truncateMiddle(displayMagnet, 72)); + const caption = isPrivate + ? 'Encrypted magnet (only decrypted locally for the owner).' + : ""; + magnetHtml = `
${label}${caption}
`; + } + + const chips = []; + if (version.deleted) { + chips.push( + 'Marked deleted' + ); + } + if (isPrivate) { + chips.push( + 'Private' + ); + } + if (version.version !== undefined) { + chips.push( + `Schema v${this.escapeHTML( + String(version.version) + )}` + ); + } + + const descriptionHtml = description + ? `

${this.escapeHTML( + description + )}

` + : '

No description provided.

'; + + 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 = ` +
+
+
+ ${this.escapeHTML(thumbnailAlt)} +
+
+
+

${this.escapeHTML( + version.title || "Untitled" + )}

+

${this.escapeHTML( + absolute + )} (${this.escapeHTML(relative)})

+
+ ${ + chips.length + ? `
${chips.join("")}
` + : "" + } +
+
+ Hosted URL: +
${urlHtml}
+
+
+ Magnet: +
${magnetHtml}
+
+
+
+
+ +
+

Description

+ ${descriptionHtml} +
+ +
+
+
Mode
+
${this.escapeHTML(version.mode || "live")}
+
+
+
d tag
+
+ ${dTagValue + ? `${this.escapeHTML( + dTagValue + )}` + : 'Not provided'} +
+
+
+
videoRootId
+
+ ${rootDisplay + ? `${rootDisplay}` + : 'Not provided'} +
+
+
+
Event ID
+
+ ${eventDisplay + ? `${eventDisplay}` + : 'Unknown'} +
+
+
+
+ `; + } + + 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 ""; + } + populateEditVideoForm(video) { if (!video || !this.editVideoModal) { return; @@ -4691,33 +5485,21 @@ class bitvidApp { return; } - // 2) Grab all known events so older overshadowed ones are included - const allEvents = Array.from(nostrClient.allEvents.values()); - - // 3) Check for older versions among *all* events, not just the active ones - if (!this.hasOlderVersion(video, allEvents)) { - this.showError("No older version exists to revert to."); - return; + if (!this.revertVideoModal) { + const initialized = await this.initRevertVideoModal(); + if (!initialized || !this.revertVideoModal) { + this.showError("Revert modal is not available right now."); + return; + } } - if (!confirm(`Revert current version of "${video.title}"?`)) { - return; - } + const history = await nostrClient.hydrateVideoHistory(video); - const originalEvent = { - id: video.id, - pubkey: video.pubkey, - tags: video.tags, - }; - - await nostrClient.revertVideo(originalEvent, this.pubkey); - - await this.loadVideos(); - this.showSuccess("Current version reverted successfully!"); - this.forceRefreshAllProfiles(); + this.populateRevertVideoModal(video, history); + this.showRevertVideoModal(); } catch (err) { console.error("Failed to revert video:", err); - this.showError("Failed to revert video. Please try again."); + this.showError("Failed to load revision history. Please try again."); } } @@ -5748,6 +6530,29 @@ class bitvidApp { /** * 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 = { diff --git a/js/nostr.js b/js/nostr.js index 4b51be4f..10a290a9 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1208,6 +1208,134 @@ class NostrClient { return null; } + /** + * Ensure we have every historical revision for a given video in memory and + * return the complete set sorted newest-first. We primarily group revisions + * by their shared `videoRootId`, but fall back to the NIP-33 `d` tag when + * working with legacy notes. The explicit `d` tag fetch is important because + * relays cannot be queried by values that only exist inside the JSON + * content. Without this pass, the UI would occasionally miss mid-history + * edits that were published from other devices. + */ + async hydrateVideoHistory(video) { + if (!video || typeof video !== "object") { + return []; + } + + const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : ""; + const targetPubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : ""; + const findDTagValue = (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 ""; + }; + + const targetDTag = findDTagValue(video.tags); + + const collectLocalMatches = () => { + const seen = new Set(); + const matches = []; + for (const candidate of this.allEvents.values()) { + if (!candidate || typeof candidate !== "object") { + continue; + } + if (targetPubkey) { + const candidatePubkey = typeof candidate.pubkey === "string" + ? candidate.pubkey.toLowerCase() + : ""; + if (candidatePubkey !== targetPubkey) { + continue; + } + } + + const candidateRoot = + typeof candidate.videoRootId === "string" ? candidate.videoRootId : ""; + const candidateDTag = findDTagValue(candidate.tags); + + const sameRoot = targetRoot && candidateRoot === targetRoot; + const sameD = targetDTag && candidateDTag === targetDTag; + + // Legacy fallbacks: some old posts reused only the "d" tag without a + // canonical videoRootId. If neither identifier exists we at least keep + // the active event so the caller can surface an informative message. + const sameLegacyRoot = + !targetRoot && candidateRoot && candidateRoot === video.id; + + if (sameRoot || sameD || sameLegacyRoot || candidate.id === video.id) { + if (!seen.has(candidate.id)) { + seen.add(candidate.id); + matches.push(candidate); + } + } + } + return matches; + }; + + let localMatches = collectLocalMatches(); + + const shouldFetchFromRelays = + localMatches.filter((entry) => !entry.deleted).length <= 1 && targetDTag; + + if (shouldFetchFromRelays && this.pool) { + const filter = { + kinds: [30078], + "#t": ["video"], + "#d": [targetDTag], + limit: 200, + }; + if (targetPubkey) { + filter.authors = [video.pubkey]; + } + + try { + const perRelay = await Promise.all( + this.relays.map(async (url) => { + try { + const events = await this.pool.list([url], [filter]); + return events || []; + } catch (err) { + if (isDevMode) { + console.warn(`[nostr] History fetch failed on ${url}:`, err); + } + return []; + } + }) + ); + + const merged = perRelay.flat(); + for (const evt of merged) { + try { + const parsed = convertEventToVideo(evt); + if (!parsed.invalid) { + this.allEvents.set(evt.id, parsed); + } + } catch (err) { + if (isDevMode) { + console.warn("[nostr] Failed to convert historical event:", err); + } + } + } + } catch (err) { + if (isDevMode) { + console.warn("[nostr] hydrateVideoHistory relay fetch error:", err); + } + } + + localMatches = collectLocalMatches(); + } + + return localMatches.sort((a, b) => b.created_at - a.created_at); + } + getActiveVideos() { return Array.from(this.activeMap.values()).sort( (a, b) => b.created_at - a.created_at