import { prepareStaticModal, openStaticModal, closeStaticModal } from "./staticModalAccessibility.js";
import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js";
import { devLogger } from "../../utils/logger.js";
const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
export class EventDetailsModal {
constructor({
app,
document: doc,
helpers = {},
callbacks = {},
} = {}) {
this.app = app;
this.document = doc || document;
this.helpers = {
escapeHtml: helpers.escapeHtml || ((str) => str),
formatAbsoluteTimestamp: helpers.formatAbsoluteTimestamp || ((ts) => new Date(ts * 1000).toLocaleString()),
safeEncodeNpub: helpers.safeEncodeNpub || ((pk) => pk),
...helpers,
};
this.callbacks = {
showError: callbacks.showError || (() => {}),
showSuccess: callbacks.showSuccess || (() => {}),
openCreatorChannel: callbacks.openCreatorChannel || (() => {}),
...callbacks,
};
this.root = null;
this.history = [];
this.currentIndex = 0;
this.isLoadingHistory = false;
this.currentVideo = null;
}
buildMarkup() {
return `
`;
}
ensureModal() {
if (this.root) {
return this.root;
}
const container = this.document.getElementById("modalContainer") || this.document.body;
const modal = this.document.createElement("div");
modal.id = "eventDetailsModal";
modal.className = "bv-modal modal-always-on-top hidden items-center justify-center p-4";
modal.innerHTML = this.buildMarkup();
this.root = modal;
// Bind generic events
const closeBtns = modal.querySelectorAll("[data-dismiss]");
closeBtns.forEach(btn => {
btn.addEventListener("click", () => this.close());
});
// Action delegation
modal.addEventListener("click", (e) => {
const trigger = e.target.closest("[data-action]");
if (!trigger) return;
const action = trigger.dataset.action;
this.handleAction(action, trigger);
});
container.appendChild(modal);
return modal;
}
handleAction(action, trigger) {
if (!this.currentVideo) return;
switch (action) {
case "copy-npub": {
const npub = this.helpers.safeEncodeNpub(this.currentVideo.pubkey);
if (npub) {
navigator.clipboard.writeText(npub)
.then(() => this.callbacks.showSuccess("Npub copied"))
.catch(() => this.callbacks.showError("Failed to copy Npub"));
}
break;
}
case "copy-timestamp": {
const ts = this.currentVideo.created_at;
if (ts) {
navigator.clipboard.writeText(String(ts))
.then(() => this.callbacks.showSuccess("Timestamp copied"))
.catch(() => this.callbacks.showError("Failed to copy timestamp"));
}
break;
}
case "copy-id": {
const id = this.currentVideo.id;
if (id) {
navigator.clipboard.writeText(id)
.then(() => this.callbacks.showSuccess("Event ID copied"))
.catch(() => this.callbacks.showError("Failed to copy ID"));
}
break;
}
case "copy-json": {
const rawEvent = this.getRawEvent(this.currentVideo);
const json = JSON.stringify(rawEvent, null, 2);
navigator.clipboard.writeText(json)
.then(() => this.callbacks.showSuccess("JSON copied"))
.catch(() => this.callbacks.showError("Failed to copy JSON"));
break;
}
case "open-channel": {
const pubkey =
this.currentVideo && typeof this.currentVideo.pubkey === "string"
? this.currentVideo.pubkey
: "";
if (pubkey) {
this.close();
this.callbacks.openCreatorChannel(pubkey);
}
break;
}
case "view-newer": {
// Newer means lower index (since history is Descending/Newest-First)
if (this.currentIndex > 0) {
this.currentIndex--;
this.renderVersion(this.history[this.currentIndex]);
this.updateNavigationState();
}
break;
}
case "view-older": {
// Older means higher index
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
this.renderVersion(this.history[this.currentIndex]);
this.updateNavigationState();
}
break;
}
}
}
updateNavigationState() {
if (!this.root) return;
// History is Newest First (Index 0 = Latest)
// Older = Index + 1
// Newer = Index - 1
const olderBtn = this.root.querySelector('[data-action="view-older"]');
const newerBtn = this.root.querySelector('[data-action="view-newer"]');
const indicator = this.root.querySelector('[data-version-indicator]');
if (olderBtn) olderBtn.disabled = this.currentIndex >= this.history.length - 1;
if (newerBtn) newerBtn.disabled = this.currentIndex <= 0;
if (indicator) {
if (this.isLoadingHistory) {
indicator.textContent = "Loading history...";
} else if (this.history.length > 0) {
// Display logical version number (Oldest = 1, Newest = N)
// index 0 (Newest) -> Version N
// index N-1 (Oldest) -> Version 1
const versionNumber = this.history.length - this.currentIndex;
indicator.textContent = `Version ${versionNumber} of ${this.history.length}`;
if (this.currentIndex === 0) {
indicator.textContent += " (Latest)";
}
} else {
indicator.textContent = "Version 1 of 1";
}
}
}
renderVersion(video) {
if (!this.root || !video) return;
this.currentVideo = video;
// Metadata
const authorNameEl = this.root.querySelector("[data-author-name]");
const authorPicEl = this.root.querySelector("[data-author-pic]");
const authorNpubEl = this.root.querySelector("[data-author-npub]");
const timestampEl = this.root.querySelector("[data-timestamp]");
const eventIdEl = this.root.querySelector("[data-event-id]");
const jsonEl = this.root.querySelector("[data-json-content]");
if (authorNameEl) {
// If we have profile data attached to the video object (which NostrService usually does)
const name = video.authorName || video.profile?.name || video.profile?.display_name || video.creatorName || video.creator?.name || "Unknown";
authorNameEl.textContent = name;
}
if (authorPicEl) {
const picCandidate = video.authorPicture || video.profile?.picture || video.creatorPicture || video.creator?.picture || "";
const pic = sanitizeProfileMediaUrl(picCandidate) || DEFAULT_PROFILE_AVATAR;
authorPicEl.src = pic;
}
if (authorNpubEl) {
const npub = this.helpers.safeEncodeNpub(video.pubkey);
authorNpubEl.textContent = npub ? `${npub.substring(0, 10)}...${npub.substring(npub.length - 10)}` : "Unknown";
authorNpubEl.title = npub || "";
}
if (timestampEl) {
timestampEl.textContent = this.helpers.formatAbsoluteTimestamp(video.created_at);
timestampEl.title = `Unix: ${video.created_at}`;
}
if (eventIdEl) {
eventIdEl.textContent = video.id;
}
if (jsonEl) {
jsonEl.textContent = "Loading raw event...";
this.currentRawEvent = null;
if (this.app?.nostrService?.nostrClient?.getEventById) {
this.app.nostrService.nostrClient
.getEventById(video.id, { includeRaw: true })
.then((result) => {
if (this.currentVideo && this.currentVideo.id === video.id) {
// Ensure we actually have a raw event (with signature)
// If getEventById returns a wrapper { video, rawEvent: null }, we must handle null.
const raw =
result && typeof result.rawEvent === "object"
? result.rawEvent
: null;
if (raw) {
this.currentRawEvent = raw;
jsonEl.textContent = JSON.stringify(raw, null, 2);
} else {
// Fallback if fetch returned nothing usable
// Do not set currentRawEvent so copy-json uses the reconstruction logic
jsonEl.textContent = JSON.stringify(
this.getRawEvent(video),
null,
2,
);
}
}
})
.catch((error) => {
devLogger.error(
"[EventDetailsModal] Failed to fetch raw event:",
error,
);
if (this.currentVideo && this.currentVideo.id === video.id) {
jsonEl.textContent = `// Failed to load raw event.\n// Falling back to known data:\n${JSON.stringify(
this.getRawEvent(video),
null,
2,
)}`;
}
});
} else {
// Fallback if client or method missing
const rawEvent = this.getRawEvent(video);
jsonEl.textContent = JSON.stringify(rawEvent, null, 2);
}
}
}
getRawEvent(video) {
if (!video) return {};
let content = video.content;
// If raw content is missing (e.g. video loaded from cache/parsed), reconstruct it
// so the user can inspect the metadata fields (thumbnail, title, etc.)
if (typeof content !== "string") {
const payload = {
videoRootId: video.videoRootId,
version: video.version,
deleted: video.deleted || false,
isPrivate: video.isPrivate,
isNsfw: video.isNsfw,
isForKids: video.isForKids,
title: video.title,
url: video.url,
magnet: video.magnet,
thumbnail: video.thumbnail,
description: video.description,
mode: video.mode,
enableComments: video.enableComments ?? true,
ws: video.ws,
xs: video.xs,
};
// Clean up undefined/empty fields to keep it tidy
Object.keys(payload).forEach((key) => {
if (payload[key] === undefined || payload[key] === null) {
delete payload[key];
}
});
content = JSON.stringify(payload);
}
return {
id: video.id,
pubkey: video.pubkey,
created_at: video.created_at,
kind: video.kind || 30078,
tags: video.tags,
content: content,
sig: video.sig || "unavailable (cached)",
};
}
async open(video) {
if (!video) return;
const modal = this.ensureModal();
this.history = [video];
this.currentIndex = 0;
this.isLoadingHistory = true;
this.renderVersion(video);
this.updateNavigationState();
openStaticModal(modal, { document: this.document });
// Fetch history
try {
const history = await this.app.nostrService.fetchVideoHistory(video);
if (history && history.length > 0) {
// Hydrate profile info for history items if possible, using current video's author info
// (Since author is same)
const profile = video.profile || {};
const enrichedHistory = history.map(h => ({
...h,
profile,
authorName: video.authorName || video.creatorName,
authorPicture: video.authorPicture || video.creatorPicture,
authorNpub: video.authorNpub || video.creatorNpub
}));
this.history = enrichedHistory;
// Find the index of the video we opened with
const idx = this.history.findIndex(h => h.id === video.id);
if (idx !== -1) {
this.currentIndex = idx;
} else {
// If not found (rare), maybe append? or just default to latest?
// Default to latest (end of array)
this.currentIndex = this.history.length - 1;
}
}
} catch (e) {
devLogger.error("[EventDetailsModal] Failed to load history", e);
} finally {
this.isLoadingHistory = false;
this.updateNavigationState();
}
}
close() {
if (this.root) {
closeStaticModal(this.root, { document: this.document });
}
this.history = [];
this.currentVideo = null;
}
}