Add Event Details modal to video card menu

This commit is contained in:
google-labs-jules[bot] committed 2026-01-14 19:29:32 +00:00
1 parent 32f6c43a87
commit 4b5a46c5bd
6 files changed
+485 -2

No files matched your search

+38
View File
@@ -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) =>
+21
View File
@@ -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;
}
}
+6
View File
@@ -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: () => {
+356
View File
@@ -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 `
<div class="bv-modal-backdrop" data-dismiss></div>
<div class="modal-sheet w-full max-w-2xl flex flex-col max-h-[90vh]" role="dialog" aria-modal="true" aria-labelledby="eventDetailsTitle">
<header class="modal-header flex items-center justify-between p-4 border-b border-border">
<h2 id="eventDetailsTitle" class="text-lg font-semibold text-text">Event Details</h2>
<button type="button" class="btn-ghost p-2 rounded-full" data-dismiss aria-label="Close">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</header>
<div class="modal-body p-4 overflow-y-auto space-y-6 flex-1">
<!-- Metadata Section -->
<section class="space-y-4">
<h3 class="text-sm font-semibold text-muted uppercase tracking-wider">Metadata</h3>
<!-- Author -->
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-full bg-panel overflow-hidden flex-shrink-0">
<img data-author-pic src="${DEFAULT_PROFILE_AVATAR}" alt="Author" class="w-full h-full object-cover">
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span data-author-name class="font-medium text-text truncate">Unknown</span>
<button type="button" data-action="open-channel" class="text-xs text-accent hover:underline">View Channel</button>
</div>
<div class="flex items-center gap-2 mt-1">
<code data-author-npub class="text-xs text-muted truncate bg-panel/50 px-1.5 py-0.5 rounded max-w-[200px]"></code>
<button type="button" data-action="copy-npub" class="text-xs text-muted hover:text-text" title="Copy Npub">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 4v12a2 2 0 002 2h8a2 2 0 002-2V7.242a2 2 0 00-.602-1.43L16.083 4.162A2 2 0 0014.661 4H10a2 2 0 00-2 2z"></path><path d="M16 4v3.5a.5.5 0 00.5.5H20"></path><path d="M4 8v12a2 2 0 002 2h8"></path></svg>
</button>
</div>
</div>
</div>
<!-- Timestamp & ID -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-panel/50 rounded p-3">
<span class="text-xs text-muted block mb-1">Posted At</span>
<div class="flex items-center gap-2">
<span data-timestamp class="text-sm text-text font-mono"></span>
<button type="button" data-action="copy-timestamp" class="text-muted hover:text-text" title="Copy Unix Timestamp">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 4v12a2 2 0 002 2h8a2 2 0 002-2V7.242a2 2 0 00-.602-1.43L16.083 4.162A2 2 0 0014.661 4H10a2 2 0 00-2 2z"></path><path d="M16 4v3.5a.5.5 0 00.5.5H20"></path><path d="M4 8v12a2 2 0 002 2h8"></path></svg>
</button>
</div>
</div>
<div class="bg-panel/50 rounded p-3">
<span class="text-xs text-muted block mb-1">Event ID</span>
<div class="flex items-center gap-2">
<code data-event-id class="text-xs text-text truncate block flex-1"></code>
<button type="button" data-action="copy-id" class="text-muted hover:text-text flex-shrink-0" title="Copy Event ID">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 4v12a2 2 0 002 2h8a2 2 0 002-2V7.242a2 2 0 00-.602-1.43L16.083 4.162A2 2 0 0014.661 4H10a2 2 0 00-2 2z"></path><path d="M16 4v3.5a.5.5 0 00.5.5H20"></path><path d="M4 8v12a2 2 0 002 2h8"></path></svg>
</button>
</div>
</div>
</div>
</section>
<!-- Raw Data Section -->
<section class="space-y-2 flex-1 flex flex-col min-h-0">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-muted uppercase tracking-wider">Raw Data</h3>
<button type="button" data-action="copy-json" class="text-xs btn-ghost px-2 py-1 rounded">
Copy JSON
</button>
</div>
<div class="relative flex-1 min-h-[200px] bg-black/80 rounded-lg border border-border overflow-hidden group">
<pre class="absolute inset-0 p-4 overflow-auto text-xs font-mono text-gray-300 scrollbar-thin"><code data-json-content></code></pre>
</div>
</section>
</div>
<footer class="modal-footer p-4 border-t border-border bg-panel/30 flex items-center justify-between gap-4">
<button type="button" data-action="prev-version" class="btn-ghost flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
<span>View Previous</span>
</button>
<div class="text-sm text-muted font-medium">
<span data-version-indicator>Version 1 of 1</span>
</div>
<button type="button" data-action="next-version" class="btn-ghost flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
<span>View Next</span>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</button>
</footer>
</div>
`;
}
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;
}
}
+11
View File
@@ -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;
}
+53 -2
View File
@@ -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;
}