From 4b5a46c5bddac7debb36eedfe914eea4561cb683 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:29:32 +0000 Subject: [PATCH] Add Event Details modal to video card menu --- js/services/nostrService.js | 38 +++ js/ui/ModalManager.js | 21 ++ js/ui/applicationBootstrap.js | 6 + js/ui/components/EventDetailsModal.js | 356 +++++++++++++++++++++++++ js/ui/components/videoMenuRenderers.js | 11 + js/ui/moreMenuController.js | 55 +++- 6 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 js/ui/components/EventDetailsModal.js 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 ` +
+ + `; + } + + 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; }