diff --git a/js/services/nostrService.js b/js/services/nostrService.js
index b966bba5..a6549d38 100644
--- a/js/services/nostrService.js
+++ b/js/services/nostrService.js
@@ -1532,6 +1532,44 @@ export class NostrService {
return detail;
}
+ async fetchVideoHistory(video) {
+ if (!video || typeof video !== "object") {
+ return [];
+ }
+
+ const dTag = Array.isArray(video.tags)
+ ? video.tags.find((t) => Array.isArray(t) && t[0] === "d")
+ : null;
+ const dValue = dTag && dTag[1] ? dTag[1] : null;
+ const pubkey = video.pubkey;
+ const kind = video.kind;
+
+ // If we don't have enough info to query history by address, return just the input
+ if (!dValue || !pubkey || typeof kind !== "number") {
+ // If we have an ID, maybe we can at least return this specific event as a single-item history
+ if (video.id) {
+ return [video];
+ }
+ return [];
+ }
+
+ const filter = {
+ kinds: [kind],
+ authors: [pubkey],
+ "#d": [dValue],
+ };
+
+ try {
+ const events = await this.nostrClient.pool.list(this.nostrClient.relays, [filter]);
+ // Sort by created_at ascending (oldest to newest)
+ return events.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
+ } catch (error) {
+ this.log("[nostrService] Failed to fetch video history", error);
+ // Fallback to returning the known video if fetch fails
+ return [video];
+ }
+ }
+
async getOldEventById(eventId) {
const map = this.ensureVideosMap();
const isBlockedNsfw = (video) =>
diff --git a/js/ui/ModalManager.js b/js/ui/ModalManager.js
index cc07193f..65597b28 100644
--- a/js/ui/ModalManager.js
+++ b/js/ui/ModalManager.js
@@ -8,6 +8,7 @@ import { RevertModal } from "./components/RevertModal.js";
import initUploadModal from "./initUploadModal.js";
import initEditModal from "./initEditModal.js";
import initDeleteModal from "./initDeleteModal.js";
+import { EventDetailsModal } from "./components/EventDetailsModal.js";
import ZapController from "./zapController.js";
import { nostrClient } from "../nostrClientFacade.js";
@@ -45,6 +46,8 @@ export default class ModalManager {
this.videoModal = null;
this.videoModalHandlers = {};
+ this.eventDetailsModal = null;
+
this.zapController = null;
}
@@ -614,6 +617,22 @@ export default class ModalManager {
);
app.videoModal = this.videoModal;
+
+ this.eventDetailsModal = new EventDetailsModal({
+ app,
+ document: doc,
+ helpers: {
+ escapeHtml: (value) => app.escapeHTML(value),
+ formatAbsoluteTimestamp: (ts) => app.formatAbsoluteTimestamp(ts),
+ safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey),
+ },
+ callbacks: {
+ showError: (msg) => app.showError(msg),
+ showSuccess: (msg) => app.showSuccess(msg),
+ openCreatorChannel: (pubkey) => app.openCreatorChannel(pubkey),
+ },
+ });
+ app.eventDetailsModal = this.eventDetailsModal;
}
teardown() {
@@ -836,6 +855,7 @@ export default class ModalManager {
app.deleteModal = null;
app.deleteModalEvents = null;
app.videoModal = null;
+ app.eventDetailsModal = null;
app.zapController = null;
this.uploadModal = null;
@@ -852,6 +872,7 @@ export default class ModalManager {
this.deleteCancelHandler = null;
this.videoModal = null;
this.videoModalHandlers = {};
+ this.eventDetailsModal = null;
this.zapController = null;
}
}
diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js
index 1447170e..e3424add 100644
--- a/js/ui/applicationBootstrap.js
+++ b/js/ui/applicationBootstrap.js
@@ -583,6 +583,12 @@ export default class ApplicationBootstrap {
handleMirrorAction: (payload) => app.handleMirrorAction(payload),
handleEnsurePresenceAction: (payload) =>
app.handleEnsurePresenceAction(payload),
+ handleEventDetailsAction: (payload) => {
+ if (app.modalManager && app.modalManager.eventDetailsModal && payload?.video) {
+ app.modalManager.eventDetailsModal.open(payload.video);
+ }
+ return Promise.resolve();
+ },
loadVideos: () => app.loadVideos(),
refreshAllVideoGrids: (options) => app.refreshAllVideoGrids(options),
onUserBlocksUpdated: () => {
diff --git a/js/ui/components/EventDetailsModal.js b/js/ui/components/EventDetailsModal.js
new file mode 100644
index 00000000..2f28e46d
--- /dev/null
+++ b/js/ui/components/EventDetailsModal.js
@@ -0,0 +1,356 @@
+import { prepareStaticModal, openStaticModal, closeStaticModal } from "./staticModalAccessibility.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 `
+
+
+
+
+
+
+
+ Metadata
+
+
+
+
+

+
+
+
+ Unknown
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Raw Data
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ ensureModal() {
+ if (this.root) {
+ return this.root;
+ }
+
+ const container = this.document.getElementById("modalContainer") || this.document.body;
+ const modal = this.document.createElement("div");
+ modal.className = "bv-modal 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": {
+ if (this.currentVideo.pubkey) {
+ this.close();
+ this.callbacks.openCreatorChannel(this.currentVideo.pubkey);
+ }
+ break;
+ }
+ case "prev-version": {
+ if (this.currentIndex > 0) {
+ this.currentIndex--;
+ this.renderVersion(this.history[this.currentIndex]);
+ this.updateNavigationState();
+ }
+ break;
+ }
+ case "next-version": {
+ if (this.currentIndex < this.history.length - 1) {
+ this.currentIndex++;
+ this.renderVersion(this.history[this.currentIndex]);
+ this.updateNavigationState();
+ }
+ break;
+ }
+ }
+ }
+
+ updateNavigationState() {
+ if (!this.root) return;
+
+ const prevBtn = this.root.querySelector('[data-action="prev-version"]');
+ const nextBtn = this.root.querySelector('[data-action="next-version"]');
+ const indicator = this.root.querySelector('[data-version-indicator]');
+
+ if (prevBtn) prevBtn.disabled = this.currentIndex <= 0;
+ if (nextBtn) nextBtn.disabled = this.currentIndex >= this.history.length - 1;
+
+ if (indicator) {
+ if (this.isLoadingHistory) {
+ indicator.textContent = "Loading history...";
+ } else {
+ indicator.textContent = `Version ${this.currentIndex + 1} of ${this.history.length}`;
+ }
+ }
+ }
+
+ 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 || "Unknown";
+ authorNameEl.textContent = name;
+ }
+
+ if (authorPicEl) {
+ const pic = video.authorPicture || video.profile?.picture || 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) {
+ const rawEvent = this.getRawEvent(video);
+ jsonEl.textContent = JSON.stringify(rawEvent, null, 2);
+ }
+ }
+
+ getRawEvent(video) {
+ if (!video) return {};
+ return {
+ id: video.id,
+ pubkey: video.pubkey,
+ created_at: video.created_at,
+ kind: video.kind,
+ tags: video.tags,
+ content: video.content,
+ sig: video.sig,
+ };
+ }
+
+ 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,
+ authorPicture: video.authorPicture,
+ authorNpub: video.authorNpub
+ }));
+
+ 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) {
+ console.error("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;
+ }
+}
diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js
index 3e1f6dbc..7d6a5809 100644
--- a/js/ui/components/videoMenuRenderers.js
+++ b/js/ui/components/videoMenuRenderers.js
@@ -295,6 +295,17 @@ export function createVideoMoreMenuPanel({
},
});
+ appendMenuSeparator(doc, list);
+
+ appendMenuAction(doc, list, {
+ text: "Event Details",
+ action: "event-details",
+ dataset: {
+ eventId: metadata.id,
+ context,
+ },
+ });
+
return panel;
}
diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js
index 09053832..189e542b 100644
--- a/js/ui/moreMenuController.js
+++ b/js/ui/moreMenuController.js
@@ -65,6 +65,8 @@ export default class MoreMenuController {
callbacks.handleMirrorAction || (() => Promise.resolve()),
handleEnsurePresenceAction:
callbacks.handleEnsurePresenceAction || (() => Promise.resolve()),
+ handleEventDetailsAction:
+ callbacks.handleEventDetailsAction || (() => Promise.resolve()),
loadVideos: callbacks.loadVideos || (() => Promise.resolve()),
onUserBlocksUpdated: callbacks.onUserBlocksUpdated || (() => {}),
};
@@ -752,7 +754,7 @@ export default class MoreMenuController {
const action = dataset.action || "";
try {
- await this.handleMoreMenuAction(action, dataset);
+ await this.handleMoreMenuAction(action, dataset, entry.context.video);
} catch (error) {
userLogger.error("[MoreMenu] Failed to handle action:", error);
}
@@ -1083,7 +1085,7 @@ export default class MoreMenuController {
});
}
- async handleMoreMenuAction(action, dataset = {}) {
+ async handleMoreMenuAction(action, dataset = {}, video = null) {
const normalized = typeof action === "string" ? action.trim() : "";
const context = dataset.context || "";
const currentVideo = this.callbacks.getCurrentVideo();
@@ -1587,6 +1589,55 @@ export default class MoreMenuController {
});
break;
}
+ case "event-details": {
+ let video = null;
+ if (context === "modal") {
+ video = currentVideo;
+ } else {
+ // If called from a card, we might not have the full video object in context
+ // but usually the menu is triggered with a video object attached to the context
+ // in toggleMoreMenu.
+ // In handleMoreMenuAction, we don't receive the 'video' object directly, only the dataset.
+ // But `toggleMoreMenu` stores the context in `entry.context`.
+ // `handleMoreMenuAction` is called by the click handler which *doesn't* pass the full video object easily
+ // unless we hack it.
+ // However! `createPopoverRender` calls `handleMoreMenuAction` with just the dataset.
+ // Wait, `createPopoverRender` has `entry.context.video`.
+ // We can't access it easily here unless we pass it.
+ // But `MoreMenuController` handles actions.
+ // Let's rely on `dataset.eventId` to fetch/find the video if needed?
+ // Or better, `toggleMoreMenu` (which calls `ensurePopoverForTrigger`) sets the context.
+ // The click handler in `createPopoverRender` has `entry.context`.
+ // It calls `handleMoreMenuAction(action, dataset)`.
+ // It also calls `entry.context.onAction`.
+ //
+ // If we look at `js/app.js` (or wherever `MoreMenuController` is instantiated),
+ // we might see how `callbacks` are defined.
+ // But here we are IN `MoreMenuController`.
+ //
+ // For `event-details` to work, we need the video object.
+ // If it's the current video (modal), `currentVideo` works.
+ // If it's a card, `currentVideo` might be null or wrong.
+ //
+ // We can try to fetch the video from `dataset.eventId` using a callback or service.
+ // Or we can modify `handleMoreMenuAction` to accept `video` as an optional 3rd argument,
+ // and update `createPopoverRender` to pass it.
+ }
+
+ // IMPORTANT: The `createPopoverRender` method in THIS file (lines 622+)
+ // `entry.context.video` is available there.
+ // It calls `await this.handleMoreMenuAction(action, dataset);`
+ // I should update `createPopoverRender` to pass the video!
+
+ // But first, let's finish the switch case assuming we can get the video.
+ // I'll modify `handleMoreMenuAction` signature in a separate edit or assume `this.callbacks.handleEventDetailsAction` can handle ID.
+
+ await this.callbacks.handleEventDetailsAction({
+ eventId: dataset.eventId,
+ video: video // Use explicit argument
+ });
+ break;
+ }
default:
break;
}