diff --git a/js/app.js b/js/app.js index 51d24c61..85d43940 100644 --- a/js/app.js +++ b/js/app.js @@ -47,6 +47,7 @@ import { VideoModal } from "./ui/components/VideoModal.js"; import { UploadModal } from "./ui/components/UploadModal.js"; import { EditModal } from "./ui/components/EditModal.js"; import { RevertModal } from "./ui/components/RevertModal.js"; +import { VideoCard } from "./ui/components/VideoCard.js"; import { getPubkey as getStoredPubkey, setPubkey as setStoredPubkey, @@ -5412,6 +5413,7 @@ class bitvidApp { // 1) If no videos if (!dedupedVideos.length) { this.renderedVideoIds.clear(); + this.videoCardInstances = []; if (this.lastRenderedVideoSignature === EMPTY_VIDEO_LIST_SIGNATURE) { return; } @@ -5456,6 +5458,7 @@ class bitvidApp { `${window.location?.origin || ""}${window.location?.pathname || ""}` || (window.location?.href ? window.location.href.split(/[?#]/)[0] : ""); const canManageBlacklist = this.canCurrentUserManageBlacklist(); + this.videoCardInstances = []; // 3) Build each card dedupedVideos.forEach((video, index) => { @@ -5485,541 +5488,87 @@ class bitvidApp { hasOlder = this.hasOlderVersion(video, fullAllEventsArray); } - const revertButton = hasOlder - ? ` - - ` - : ""; - - const gearMenu = canEdit - ? ` -
- - -
- ` - : ""; - - const blacklistMenuItem = canManageBlacklist - ? ` - ` - : ""; - const pointerInfo = this.deriveVideoPointerInfo(video); if (pointerInfo) { this.persistWatchHistoryMetadataForVideo(video, pointerInfo); } - const pointerKeyAttr = pointerInfo?.key - ? this.escapeHTML(pointerInfo.key) - : ""; - const pointerTypeAttr = - pointerInfo?.pointer?.[0] === "a" - ? "a" - : pointerInfo?.pointer?.[0] === "e" - ? "e" - : ""; - const pointerValueAttr = pointerInfo?.pointer?.[1] - ? this.escapeHTML(pointerInfo.pointer[1]) - : ""; - const pointerRelayAttr = - pointerInfo?.pointer?.length > 2 && pointerInfo.pointer[2] - ? this.escapeHTML(pointerInfo.pointer[2]) - : ""; - const relayAttr = pointerRelayAttr - ? ` data-pointer-relay="${pointerRelayAttr}"` - : ""; - const removeHistoryMenuItem = - pointerKeyAttr && pointerTypeAttr && pointerValueAttr - ? ` - ` - : ""; + const videoCard = new VideoCard({ + document, + video, + index, + shareUrl, + pointerInfo, + timeAgo, + highlightClass, + animationClass, + capabilities: { + canEdit, + canDelete: canEdit, + canRevert: hasOlder, + canManageBlacklist, + }, + helpers: { + escapeHtml: (value) => this.escapeHTML(value), + isMagnetSupported: (magnet) => isValidMagnetUri(magnet), + toLocaleString: (value) => + typeof value === "number" ? value.toLocaleString() : value, + }, + assets: { + fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC, + unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE, + }, + state: { loadedThumbnails: this.loadedThumbnails }, + ensureGlobalMoreMenuHandlers: () => this.ensureGlobalMoreMenuHandlers(), + onRequestCloseAllMenus: () => this.closeAllMoreMenus(), + }); - const moreMenu = ` -
- - -
- `; - - const cardControls = ` -
- ${moreMenu}${gearMenu} -
- `; - - const trimmedUrl = typeof video.url === "string" ? video.url.trim() : ""; - const trimmedMagnet = - typeof video.magnet === "string" ? video.magnet.trim() : ""; - const legacyInfoHash = - typeof video.infoHash === "string" ? video.infoHash.trim() : ""; - const magnetCandidate = trimmedMagnet || legacyInfoHash; - const magnetSupported = isValidMagnetUri(magnetCandidate); - const magnetProvided = magnetCandidate.length > 0; - const playbackUrl = trimmedUrl; - const playbackMagnet = magnetCandidate; - const showUnsupportedTorrentBadge = - !trimmedUrl && magnetProvided && !magnetSupported; - const torrentWarningHtml = showUnsupportedTorrentBadge - ? ` -

- WebTorrent fallback unavailable (magnet missing btih info hash) -

- ` - : ""; - - const urlBadgeHtml = trimmedUrl - ? this.getUrlHealthPlaceholderMarkup({ includeMargin: false }) - : ""; - const torrentHealthBadgeHtml = - magnetSupported && magnetProvided - ? this.getTorrentHealthBadgeMarkup({ includeMargin: false }) - : ""; - const connectionBadgesHtml = - urlBadgeHtml || torrentHealthBadgeHtml - ? ` -
- ${urlBadgeHtml}${torrentHealthBadgeHtml} -
- ` - : ""; - - const showDiscussionCount = video.enableComments !== false; - let discussionCountHtml = ""; - if (showDiscussionCount) { - let initialDiscussionCount = null; - if (typeof video.discussionCount === "number") { - initialDiscussionCount = video.discussionCount; - } else if (typeof video.discussionCount === "string") { - const trimmedCount = video.discussionCount.trim(); - if (trimmedCount) { - const parsed = Number.parseInt(trimmedCount, 10); - if (Number.isFinite(parsed)) { - initialDiscussionCount = parsed; - } - } - } - - if ( - initialDiscussionCount !== null && - Number.isFinite(initialDiscussionCount) && - initialDiscussionCount >= 0 - ) { - const safeDiscussionCount = Math.floor(initialDiscussionCount); - discussionCountHtml = ` -
- ${safeDiscussionCount.toLocaleString()} - notes -
- `; - } - } - - const rawThumbnail = - typeof video.thumbnail === "string" ? video.thumbnail.trim() : ""; - const escapedThumbnail = rawThumbnail - ? this.escapeHTML(rawThumbnail) - : ""; - const previouslyLoadedThumbnail = - this.loadedThumbnails.get(video.id) || ""; - const shouldLazyLoadThumbnail = - escapedThumbnail && previouslyLoadedThumbnail !== escapedThumbnail; - const shouldAnimateThumbnail = - !!escapedThumbnail && previouslyLoadedThumbnail !== escapedThumbnail; - const thumbnailAttrLines = ["data-video-thumbnail=\"true\""]; - if (shouldLazyLoadThumbnail) { - thumbnailAttrLines.push( - `src=\"${FALLBACK_THUMBNAIL_SRC}\"`, - `data-fallback-src=\"${FALLBACK_THUMBNAIL_SRC}\"`, - `data-lazy=\"${escapedThumbnail}\"`, - 'loading="lazy"', - 'decoding="async"' - ); - } else { - thumbnailAttrLines.push( - `src=\"${escapedThumbnail || FALLBACK_THUMBNAIL_SRC}\"`, - `data-fallback-src=\"${FALLBACK_THUMBNAIL_SRC}\"`, - 'loading="lazy"', - 'decoding="async"' - ); - } - thumbnailAttrLines.push( - `alt=\"${this.escapeHTML(video.title)}\"` - ); - - const metadataPieces = [`${timeAgo}`]; - if (pointerInfo) { - metadataPieces.push( - '', - `– views` - ); - } - const metadataHtml = metadataPieces.join(""); - - const cardHtml = ` -
- - -
- -
-
-
- -

- ${this.escapeHTML(video.title)} -

-
-
-
- Placeholder -
-
-

- Loading name... -

-
- ${metadataHtml} -
-
-
- ${cardControls} -
- ${connectionBadgesHtml} - ${discussionCountHtml} - ${torrentWarningHtml} -
-
- `; - - const template = document.createElement("template"); - template.innerHTML = cardHtml.trim(); - const cardEl = template.content.firstElementChild; - if (cardEl) { - if (pointerInfo && pointerInfo.key) { - cardEl.dataset.pointerKey = pointerInfo.key; - const pointerType = pointerInfo.pointer?.[0]; - if (pointerType) { - cardEl.dataset.pointerType = pointerType; - } else if (cardEl.dataset.pointerType) { - delete cardEl.dataset.pointerType; - } - const pointerValue = pointerInfo.pointer?.[1]; - if (pointerValue) { - cardEl.dataset.pointerValue = pointerValue; - } else if (cardEl.dataset.pointerValue) { - delete cardEl.dataset.pointerValue; - } - const pointerRelay = - pointerInfo.pointer?.length > 2 ? pointerInfo.pointer[2] : ""; - if (pointerRelay) { - cardEl.dataset.pointerRelay = pointerRelay; - } else if (cardEl.dataset.pointerRelay) { - delete cardEl.dataset.pointerRelay; - } - } else { - delete cardEl.dataset.pointerKey; - delete cardEl.dataset.pointerType; - delete cardEl.dataset.pointerValue; - if (cardEl.dataset.pointerRelay) { - delete cardEl.dataset.pointerRelay; - } - } - - cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false"; - if (typeof video.pubkey === "string" && video.pubkey) { - cardEl.dataset.ownerPubkey = video.pubkey; - } else if (cardEl.dataset.ownerPubkey) { - delete cardEl.dataset.ownerPubkey; - } - - if (trimmedUrl) { - cardEl.dataset.urlHealthState = "checking"; - if (cardEl.dataset.urlHealthReason) { - delete cardEl.dataset.urlHealthReason; - } - cardEl.dataset.urlHealthEventId = video.id || ""; - cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); - } else { - cardEl.dataset.urlHealthState = "offline"; - cardEl.dataset.urlHealthReason = "missing-source"; - if (cardEl.dataset.urlHealthEventId) { - delete cardEl.dataset.urlHealthEventId; - } - if (cardEl.dataset.urlHealthUrl) { - delete cardEl.dataset.urlHealthUrl; - } - } - if (magnetProvided && magnetSupported) { - cardEl.dataset.streamHealthState = "checking"; - if (cardEl.dataset.streamHealthReason) { - delete cardEl.dataset.streamHealthReason; - } - } else { - cardEl.dataset.streamHealthState = "unhealthy"; - cardEl.dataset.streamHealthReason = magnetProvided - ? "unsupported" - : "missing-source"; - } - - if (pointerInfo) { - this.registerVideoViewCountElement(cardEl, pointerInfo); - } - - const thumbnailEl = cardEl.querySelector("[data-video-thumbnail]"); - if (thumbnailEl) { - const markThumbnailAsLoaded = () => { - if (!escapedThumbnail) { - return; - } - - if (shouldAnimateThumbnail) { - // Flag the element so CSS runs a one-time fade. js/index.js scrubs - // this attribute in the `animationend` handler so reusing the same - // DOM node later will not re-trigger the animation and flash. - if (thumbnailEl.dataset.thumbnailLoaded !== "true") { - thumbnailEl.dataset.thumbnailLoaded = "true"; - } - } else if (thumbnailEl.dataset.thumbnailLoaded) { - delete thumbnailEl.dataset.thumbnailLoaded; - } - - this.loadedThumbnails.set(video.id, escapedThumbnail); - }; - - const handleThumbnailLoad = () => { - if (thumbnailEl.dataset.thumbnailLoaded === "true") { - return; - } - - const hasPendingLazySrc = - typeof thumbnailEl.dataset.lazy === "string" && - thumbnailEl.dataset.lazy.trim().length > 0; - - if (hasPendingLazySrc) { - return; - } - - if (thumbnailEl.dataset.thumbnailFailed || !escapedThumbnail) { - return; - } - - const fallbackAttr = - (typeof thumbnailEl.dataset.fallbackSrc === "string" - ? thumbnailEl.dataset.fallbackSrc.trim() - : "") || - thumbnailEl.getAttribute("data-fallback-src") || - ""; - - const currentSrc = thumbnailEl.currentSrc || thumbnailEl.src || ""; - const isFallbackSrc = - !!fallbackAttr && - !!currentSrc && - (currentSrc === fallbackAttr || currentSrc.endsWith(fallbackAttr)); - - if (isFallbackSrc) { - return; - } - - if ( - (thumbnailEl.naturalWidth === 0 && - thumbnailEl.naturalHeight === 0) || - !currentSrc - ) { - return; - } - - markThumbnailAsLoaded(); - - thumbnailEl.removeEventListener("load", handleThumbnailLoad); - }; - const handleThumbnailError = () => { - if ( - escapedThumbnail && - this.loadedThumbnails.get(video.id) === escapedThumbnail - ) { - this.loadedThumbnails.delete(video.id); - } - - if (thumbnailEl.dataset.thumbnailLoaded) { - delete thumbnailEl.dataset.thumbnailLoaded; - } - - thumbnailEl.removeEventListener("load", handleThumbnailLoad); - }; - - thumbnailEl.addEventListener("load", handleThumbnailLoad); - thumbnailEl.addEventListener("error", handleThumbnailError, { - once: true, - }); - - if (thumbnailEl.complete) { - handleThumbnailLoad(); - } - } - - if (showUnsupportedTorrentBadge) { - cardEl.dataset.torrentSupported = "false"; - } else if (magnetProvided && magnetSupported) { - cardEl.dataset.torrentSupported = "true"; - } - - if (magnetProvided) { - cardEl.dataset.magnet = playbackMagnet; - } else if (cardEl.dataset.magnet) { - delete cardEl.dataset.magnet; - } - const interactiveEls = cardEl.querySelectorAll("[data-video-id]"); - // We intentionally leave the data-play-* attributes blank in cardHtml and - // assign them after template parsing so the raw URL/magnet strings avoid - // HTML entity escaping in the literal markup and keep any sensitive - // magnet payloads out of the static DOM text. - // The play URL is stored URL-encoded to keep spaces and query params - // intact inside data-* attributes; attachVideoListHandler() decodes it - // before playback. - interactiveEls.forEach((el) => { - if (!el.dataset) return; - el.dataset.playUrl = encodeURIComponent(playbackUrl || ""); - el.dataset.playMagnet = playbackMagnet || ""; - if (magnetProvided) { - el.dataset.torrentSupported = magnetSupported ? "true" : "false"; - } + videoCard.onEdit = ({ video: editVideo, index: editIndex }) => { + this.handleEditVideo({ + eventId: editVideo.id, + index: Number.isFinite(editIndex) ? editIndex : null, }); + }; - if (trimmedUrl) { - const badgeEl = cardEl.querySelector("[data-url-health-state]"); - if (badgeEl) { - badgeEl.dataset.urlHealthEventId = video.id || ""; - badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); - } - } else { - const badgeEl = cardEl.querySelector("[data-url-health-state]"); - if (badgeEl) { - if (badgeEl.dataset.urlHealthEventId) { - delete badgeEl.dataset.urlHealthEventId; - } - if (badgeEl.dataset.urlHealthUrl) { - delete badgeEl.dataset.urlHealthUrl; - } - } - } + videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => { + this.handleRevertVideo({ + eventId: revertVideo.id, + index: Number.isFinite(revertIndex) ? revertIndex : null, + }); + }; + + videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => { + this.handleFullDeleteVideo({ + eventId: deleteVideo.id, + index: Number.isFinite(deleteIndex) ? deleteIndex : null, + }); + }; + + videoCard.onMoreAction = ({ dataset }) => { + const action = dataset?.action || ""; + this.handleMoreMenuAction(action, dataset || {}); + }; + + videoCard.onAuthorNavigate = ({ pubkey }) => { + this.goToProfile(pubkey); + }; + + const cardEl = videoCard.getRoot(); + if (!cardEl) { + return; } + + if (pointerInfo) { + this.registerVideoViewCountElement(cardEl, pointerInfo); + } + if (video && video.id) { this.videosMap.set(video.id, video); } fragment.appendChild(cardEl); this.renderedVideoIds.add(video.id); + this.videoCardInstances.push(videoCard); }); // Clear old content, add new @@ -6036,104 +5585,14 @@ class bitvidApp { // with HTTP 200 + empty body). We set up the listeners before kicking off // any lazy-loading observers so cached failures are covered as well. this.bindThumbnailFallbacks(this.videoList); - this.attachMoreMenuHandlers(this.videoList); // Lazy-load images const lazyEls = this.videoList.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => this.mediaLoader.observe(el)); - // GEAR MENU / button event listeners... - const gearButtons = this.videoList.querySelectorAll( - "[data-settings-dropdown]" - ); - gearButtons.forEach((button) => { - button.addEventListener("click", () => { - const index = button.getAttribute("data-settings-dropdown"); - const dropdown = document.getElementById(`settingsDropdown-${index}`); - if (dropdown) { - dropdown.classList.toggle("hidden"); - } - }); - }); - - // Edit button - const editButtons = this.videoList.querySelectorAll("[data-edit-index]"); - editButtons.forEach((button) => { - button.addEventListener("click", () => { - const indexAttr = button.getAttribute("data-edit-index"); - const eventId = button.getAttribute("data-edit-event-id") || ""; - const index = Number.parseInt(indexAttr, 10); - const dropdown = document.getElementById(`settingsDropdown-${indexAttr}`); - if (dropdown) dropdown.classList.add("hidden"); - this.handleEditVideo({ - eventId, - index: Number.isNaN(index) ? null : index, - }); - }); - }); - - // Revert button - const revertButtons = this.videoList.querySelectorAll( - "[data-revert-index]" - ); - revertButtons.forEach((button) => { - button.addEventListener("click", () => { - const indexAttr = button.getAttribute("data-revert-index"); - const eventId = button.getAttribute("data-revert-event-id") || ""; - const index = Number.parseInt(indexAttr, 10); - const dropdown = document.getElementById(`settingsDropdown-${indexAttr}`); - if (dropdown) dropdown.classList.add("hidden"); - this.handleRevertVideo({ - eventId, - index: Number.isNaN(index) ? null : index, - }); - }); - }); - - // Delete All button - const deleteAllButtons = this.videoList.querySelectorAll( - "[data-delete-all-index]" - ); - deleteAllButtons.forEach((button) => { - button.addEventListener("click", () => { - const indexAttr = button.getAttribute("data-delete-all-index"); - const eventId = button.getAttribute("data-delete-all-event-id") || ""; - const index = Number.parseInt(indexAttr, 10); - const dropdown = document.getElementById(`settingsDropdown-${indexAttr}`); - if (dropdown) dropdown.classList.add("hidden"); - this.handleFullDeleteVideo({ - eventId, - index: Number.isNaN(index) ? null : index, - }); - }); - }); - // 2) After building cards, do one batch profile fetch this.batchFetchProfiles(authorSet); - // === NEW: attach click listeners to .author-pic and .author-name - const authorPics = this.videoList.querySelectorAll(".author-pic"); - authorPics.forEach((pic) => { - pic.style.cursor = "pointer"; - pic.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); // avoids playing the video - const pubkey = pic.getAttribute("data-pubkey"); - this.goToProfile(pubkey); - }); - }); - - const authorNames = this.videoList.querySelectorAll(".author-name"); - authorNames.forEach((nameEl) => { - nameEl.style.cursor = "pointer"; - nameEl.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); // avoids playing the video - const pubkey = nameEl.getAttribute("data-pubkey"); - this.goToProfile(pubkey); - }); - }); - this.refreshVideoDiscussionCounts(dedupedVideos); this.pruneDetachedViewCountElements(); } @@ -6437,6 +5896,20 @@ class bitvidApp { } closeAllMoreMenus() { + if (Array.isArray(this.videoCardInstances) && this.videoCardInstances.length) { + this.videoCardInstances.forEach((card) => { + if (!card) { + return; + } + if (typeof card.closeMoreMenu === "function") { + card.closeMoreMenu(); + } + if (typeof card.closeSettingsMenu === "function") { + card.closeSettingsMenu(); + } + }); + } + const menus = document.querySelectorAll("[data-more-menu]"); menus.forEach((menu) => { if (menu instanceof HTMLElement) { diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js new file mode 100644 index 00000000..be262245 --- /dev/null +++ b/js/ui/components/VideoCard.js @@ -0,0 +1,1134 @@ +export class VideoCard { + constructor({ + document: doc, + video, + index = 0, + shareUrl = "#", + timeAgo = "", + pointerInfo = null, + highlightClass = "", + animationClass = "", + capabilities = {}, + formatters = {}, + helpers = {}, + assets = {}, + state = {}, + ensureGlobalMoreMenuHandlers, + onRequestCloseAllMenus, + } = {}) { + if (!doc) { + throw new Error("VideoCard requires a document reference."); + } + if (!video || !video.id || !video.title) { + throw new Error("VideoCard requires a video with id and title."); + } + + this.document = doc; + this.window = doc.defaultView || globalThis; + this.video = video; + this.index = index; + this.shareUrl = shareUrl; + this.highlightClass = highlightClass || ""; + this.animationClass = animationClass || ""; + this.pointerInfo = pointerInfo; + this.capabilities = { + canEdit: false, + canDelete: false, + canRevert: false, + canManageBlacklist: false, + ...capabilities, + }; + this.formatters = { + formatTimeAgo: formatters.formatTimeAgo, + formatNumber: formatters.formatNumber, + }; + this.helpers = { + escapeHtml: helpers.escapeHtml, + isMagnetSupported: helpers.isMagnetSupported, + toLocaleString: helpers.toLocaleString, + }; + this.assets = { + fallbackThumbnailSrc: assets.fallbackThumbnailSrc || "", + unsupportedBtihMessage: assets.unsupportedBtihMessage || "", + }; + this.state = { + loadedThumbnails: + state.loadedThumbnails instanceof Map ? state.loadedThumbnails : null, + }; + this.ensureGlobalMoreMenuHandlers = + typeof ensureGlobalMoreMenuHandlers === "function" + ? ensureGlobalMoreMenuHandlers + : null; + this.onRequestCloseAllMenus = + typeof onRequestCloseAllMenus === "function" + ? onRequestCloseAllMenus + : null; + + this.callbacks = { + onPlay: null, + onEdit: null, + onRevert: null, + onDelete: null, + onMoreAction: null, + onAuthorNavigate: null, + }; + + this.root = null; + this.anchorEl = null; + this.titleEl = null; + this.thumbnailEl = null; + this.settingsButton = null; + this.settingsDropdown = null; + this.editButton = null; + this.revertButton = null; + this.deleteButton = null; + this.moreMenuButton = null; + this.moreMenu = null; + this.urlHealthBadgeEl = null; + this.torrentHealthBadgeEl = null; + this.viewCountEl = null; + this.discussionCountEl = null; + this.authorPicEl = null; + this.authorNameEl = null; + + this.playbackUrl = + typeof video.url === "string" ? video.url.trim() : ""; + const magnet = + (typeof video.magnet === "string" ? video.magnet.trim() : "") || + (typeof video.infoHash === "string" ? video.infoHash.trim() : ""); + this.playbackMagnet = magnet; + this.magnetProvided = magnet.length > 0; + this.magnetSupported = this.helpers.isMagnetSupported + ? this.helpers.isMagnetSupported(magnet) + : false; + this.showUnsupportedTorrentBadge = + !this.playbackUrl && this.magnetProvided && !this.magnetSupported; + + this.timeAgo = + timeAgo || + (typeof this.formatters.formatTimeAgo === "function" + ? this.formatters.formatTimeAgo(video.created_at) + : ""); + + this.build(); + } + + set onPlay(fn) { + this.callbacks.onPlay = typeof fn === "function" ? fn : null; + } + + set onEdit(fn) { + this.callbacks.onEdit = typeof fn === "function" ? fn : null; + } + + set onRevert(fn) { + this.callbacks.onRevert = typeof fn === "function" ? fn : null; + } + + set onDelete(fn) { + this.callbacks.onDelete = typeof fn === "function" ? fn : null; + } + + set onMoreAction(fn) { + this.callbacks.onMoreAction = typeof fn === "function" ? fn : null; + } + + set onAuthorNavigate(fn) { + this.callbacks.onAuthorNavigate = typeof fn === "function" ? fn : null; + } + + getRoot() { + return this.root; + } + + getUrlHealthBadgeElement() { + return this.urlHealthBadgeEl; + } + + getTorrentHealthBadgeElement() { + return this.torrentHealthBadgeEl; + } + + getViewCountElement() { + return this.viewCountEl; + } + + getDiscussionCountElement() { + return this.discussionCountEl; + } + + closeMoreMenu() { + if (this.moreMenu) { + this.moreMenu.classList.add("hidden"); + } + if (this.moreMenuButton) { + this.moreMenuButton.setAttribute("aria-expanded", "false"); + } + } + + closeSettingsMenu() { + if (this.settingsDropdown) { + this.settingsDropdown.classList.add("hidden"); + } + } + + build() { + const doc = this.document; + + const root = this.createElement("div", { + classNames: [ + "video-card", + "bg-gray-900", + "rounded-lg", + "overflow-hidden", + "shadow-lg", + "hover:shadow-2xl", + "transition-all", + "duration-300", + ], + }); + + this.applyClassListFromString(root, this.highlightClass); + this.applyClassListFromString(root, this.animationClass); + + this.root = root; + + this.applyPointerDataset(); + this.applyOwnerDataset(); + this.applySourceDatasets(); + + const anchor = this.createElement("a", { + classNames: ["block", "cursor-pointer", "relative", "group"], + attrs: { + href: this.shareUrl, + }, + }); + anchor.dataset.videoId = this.video.id; + this.anchorEl = anchor; + + const ratio = this.createElement("div", { + classNames: ["ratio-16-9"], + }); + const thumbnail = this.buildThumbnail(); + ratio.appendChild(thumbnail); + anchor.appendChild(ratio); + + const content = this.createElement("div", { classNames: ["p-4"] }); + + const title = this.createElement("h3", { + classNames: [ + "text-lg", + "font-bold", + "text-white", + "line-clamp-2", + "hover:text-blue-400", + "cursor-pointer", + "mb-3", + ], + textContent: this.video.title, + }); + title.dataset.videoId = this.video.id; + this.titleEl = title; + + const header = this.createElement("div", { + classNames: ["flex", "items-center", "justify-between"], + }); + + const authorSection = this.buildAuthorSection(); + const controls = this.buildControls(); + + header.appendChild(authorSection); + if (controls) { + header.appendChild(controls); + } + + content.appendChild(title); + content.appendChild(header); + + const badgesContainer = this.buildBadgesContainer(); + if (badgesContainer) { + content.appendChild(badgesContainer); + } + + const discussion = this.buildDiscussionCount(); + if (discussion) { + content.appendChild(discussion); + } + + if (this.showUnsupportedTorrentBadge) { + const warning = this.createElement("p", { + classNames: ["mt-3", "text-xs", "text-amber-300"], + attrs: { title: this.assets.unsupportedBtihMessage || "" }, + textContent: + "WebTorrent fallback unavailable (magnet missing btih info hash)", + }); + warning.dataset.torrentStatus = "unsupported"; + content.appendChild(warning); + } + + root.appendChild(anchor); + root.appendChild(content); + + this.applyPlaybackDatasets(); + this.bindEvents(); + } + + buildThumbnail() { + const img = this.createElement("img"); + img.dataset.videoThumbnail = "true"; + + const fallbackSrc = this.assets.fallbackThumbnailSrc || ""; + const thumbnailUrl = + typeof this.video.thumbnail === "string" + ? this.video.thumbnail.trim() + : ""; + + const cachedValue = this.state.loadedThumbnails?.get(this.video.id) || ""; + const shouldLazyLoad = !!thumbnailUrl && cachedValue !== thumbnailUrl; + const shouldAnimate = !!thumbnailUrl && cachedValue !== thumbnailUrl; + + if (shouldLazyLoad) { + if (fallbackSrc) { + img.src = fallbackSrc; + img.dataset.fallbackSrc = fallbackSrc; + } + img.dataset.lazy = thumbnailUrl; + } else { + img.src = thumbnailUrl || fallbackSrc; + if (fallbackSrc) { + img.dataset.fallbackSrc = fallbackSrc; + } + } + + img.loading = "lazy"; + img.decoding = "async"; + img.alt = this.video.title || ""; + + this.thumbnailEl = img; + + const markThumbnailAsLoaded = () => { + if (!thumbnailUrl) { + return; + } + + if (shouldAnimate) { + if (img.dataset.thumbnailLoaded !== "true") { + img.dataset.thumbnailLoaded = "true"; + } + } else if (img.dataset.thumbnailLoaded) { + delete img.dataset.thumbnailLoaded; + } + + if (this.state.loadedThumbnails) { + this.state.loadedThumbnails.set(this.video.id, thumbnailUrl); + } + }; + + const handleLoad = () => { + if (img.dataset.thumbnailLoaded === "true") { + return; + } + + const hasPendingLazySrc = + typeof img.dataset.lazy === "string" && img.dataset.lazy.trim().length; + + if (hasPendingLazySrc) { + return; + } + + if (img.dataset.thumbnailFailed || !thumbnailUrl) { + return; + } + + const fallbackAttr = + (typeof img.dataset.fallbackSrc === "string" + ? img.dataset.fallbackSrc.trim() + : "") || fallbackSrc || ""; + + const currentSrc = img.currentSrc || img.src || ""; + const isFallback = + !!fallbackAttr && + !!currentSrc && + (currentSrc === fallbackAttr || currentSrc.endsWith(fallbackAttr)); + + if (isFallback) { + return; + } + + if ((img.naturalWidth === 0 && img.naturalHeight === 0) || !currentSrc) { + return; + } + + markThumbnailAsLoaded(); + img.removeEventListener("load", handleLoad); + }; + + const handleError = () => { + if (thumbnailUrl && this.state.loadedThumbnails) { + const cached = this.state.loadedThumbnails.get(this.video.id); + if (cached === thumbnailUrl) { + this.state.loadedThumbnails.delete(this.video.id); + } + } + + if (img.dataset.thumbnailLoaded) { + delete img.dataset.thumbnailLoaded; + } + + img.removeEventListener("load", handleLoad); + }; + + img.addEventListener("load", handleLoad); + img.addEventListener("error", handleError, { once: true }); + + if (img.complete) { + handleLoad(); + } + + return img; + } + + buildAuthorSection() { + const wrapper = this.createElement("div", { + classNames: ["flex", "items-center", "space-x-3"], + }); + + const avatarWrapper = this.createElement("div", { + classNames: [ + "w-8", + "h-8", + "rounded-full", + "bg-gray-700", + "overflow-hidden", + "flex", + "items-center", + "justify-center", + ], + }); + + const avatar = this.createElement("img", { + attrs: { + src: "assets/svg/default-profile.svg", + alt: "Placeholder", + }, + }); + avatar.classList.add("author-pic"); + if (this.video.pubkey) { + avatar.dataset.pubkey = this.video.pubkey; + } + avatar.style.cursor = "pointer"; + + avatarWrapper.appendChild(avatar); + + const authorMeta = this.createElement("div", { classNames: ["min-w-0"] }); + + const authorName = this.createElement("p", { + classNames: ["text-sm", "text-gray-400", "author-name"], + textContent: "Loading name...", + }); + if (this.video.pubkey) { + authorName.dataset.pubkey = this.video.pubkey; + } + authorName.style.cursor = "pointer"; + + const metadata = this.createElement("div", { + classNames: ["flex", "items-center", "text-xs", "text-gray-500", "mt-1"], + }); + + const timeEl = this.createElement("span", { + textContent: this.timeAgo, + }); + metadata.appendChild(timeEl); + + if (this.pointerInfo && this.pointerInfo.key) { + const dot = this.createElement("span", { + classNames: ["mx-1", "text-gray-600"], + textContent: "•", + }); + dot.setAttribute("aria-hidden", "true"); + + const view = this.createElement("span", { + classNames: ["view-count-text"], + textContent: "– views", + }); + view.dataset.viewCount = ""; + view.dataset.viewPointer = this.pointerInfo.key; + + metadata.appendChild(dot); + metadata.appendChild(view); + + this.viewCountEl = view; + } + + authorMeta.appendChild(authorName); + authorMeta.appendChild(metadata); + + wrapper.appendChild(avatarWrapper); + wrapper.appendChild(authorMeta); + + this.authorPicEl = avatar; + this.authorNameEl = authorName; + + return wrapper; + } + + buildControls() { + const doc = this.document; + const container = this.createElement("div", { + classNames: ["flex", "items-center"], + }); + + const moreMenu = this.buildMoreMenu(); + if (moreMenu) { + container.appendChild(moreMenu); + } + + if (this.capabilities.canEdit) { + const wrapper = this.createElement("div", { + classNames: ["relative", "inline-block", "ml-3", "overflow-visible"], + }); + const button = this.createElement("button", { + classNames: [ + "inline-flex", + "items-center", + "p-2", + "rounded-full", + "text-gray-400", + "hover:text-gray-200", + "hover:bg-gray-800", + "focus:outline-none", + "focus:ring-2", + "focus:ring-blue-500", + ], + attrs: { + type: "button", + }, + }); + button.dataset.settingsDropdown = String(this.index); + + const icon = doc.createElement("img"); + icon.src = "assets/svg/video-settings-gear.svg"; + icon.alt = "Settings"; + icon.classList.add("w-5", "h-5"); + button.appendChild(icon); + + const dropdown = this.createElement("div", { + classNames: [ + "hidden", + "absolute", + "right-0", + "bottom-full", + "mb-2", + "w-32", + "rounded-md", + "shadow-lg", + "bg-gray-800", + "ring-1", + "ring-black", + "ring-opacity-5", + "z-50", + ], + }); + dropdown.id = `settingsDropdown-${this.index}`; + + const list = this.createElement("div", { + classNames: ["py-1"], + }); + + const editButton = this.createElement("button", { + classNames: [ + "block", + "w-full", + "text-left", + "px-4", + "py-2", + "text-sm", + "text-gray-100", + "hover:bg-gray-700", + ], + textContent: "Edit", + }); + editButton.dataset.editIndex = String(this.index); + editButton.dataset.editEventId = this.video.id; + list.appendChild(editButton); + this.editButton = editButton; + + if (this.capabilities.canRevert) { + const revertButton = this.createElement("button", { + classNames: [ + "block", + "w-full", + "text-left", + "px-4", + "py-2", + "text-sm", + "text-red-400", + "hover:bg-red-700", + "hover:text-white", + ], + textContent: "Revert", + }); + revertButton.dataset.revertIndex = String(this.index); + revertButton.dataset.revertEventId = this.video.id; + list.appendChild(revertButton); + this.revertButton = revertButton; + } + + if (this.capabilities.canDelete) { + const deleteButton = this.createElement("button", { + classNames: [ + "block", + "w-full", + "text-left", + "px-4", + "py-2", + "text-sm", + "text-red-400", + "hover:bg-red-700", + "hover:text-white", + ], + textContent: "Delete All", + }); + deleteButton.dataset.deleteAllIndex = String(this.index); + deleteButton.dataset.deleteAllEventId = this.video.id; + list.appendChild(deleteButton); + this.deleteButton = deleteButton; + } + + dropdown.appendChild(list); + wrapper.appendChild(button); + wrapper.appendChild(dropdown); + + container.appendChild(wrapper); + + this.settingsButton = button; + this.settingsDropdown = dropdown; + } + + return container; + } + + buildMoreMenu() { + const wrapper = this.createElement("div", { + classNames: ["relative", "inline-block", "ml-1", "overflow-visible"], + }); + wrapper.dataset.moreMenuWrapper = "true"; + + const button = this.createElement("button", { + classNames: [ + "inline-flex", + "items-center", + "justify-center", + "w-10", + "h-10", + "p-2", + "rounded-full", + "text-gray-400", + "hover:text-gray-200", + "hover:bg-gray-800", + "focus:outline-none", + "focus:ring-2", + "focus:ring-blue-500", + ], + attrs: { + type: "button", + "aria-haspopup": "true", + "aria-expanded": "false", + "aria-label": "More options", + }, + }); + button.dataset.moreDropdown = String(this.index); + + const icon = this.document.createElement("img"); + icon.src = "assets/svg/ellipsis.svg"; + icon.alt = "More"; + icon.classList.add("w-5", "h-5", "object-contain"); + button.appendChild(icon); + + const dropdown = this.createElement("div", { + classNames: [ + "hidden", + "absolute", + "right-0", + "bottom-full", + "mb-2", + "w-40", + "rounded-md", + "shadow-lg", + "bg-gray-800", + "ring-1", + "ring-black", + "ring-opacity-5", + "z-50", + ], + }); + dropdown.id = `moreDropdown-${this.index}`; + dropdown.dataset.moreMenu = "true"; + dropdown.setAttribute("role", "menu"); + + const list = this.createElement("div", { + classNames: ["py-1"], + }); + + const addActionButton = (text, action, extraDataset = {}, classes = []) => { + const btn = this.createElement("button", { + classNames: [ + "block", + "w-full", + "text-left", + "px-4", + "py-2", + "text-sm", + ...classes, + ], + textContent: text, + }); + btn.dataset.action = action; + Object.entries(extraDataset || {}).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + btn.dataset[key] = String(value); + }); + list.appendChild(btn); + return btn; + }; + + addActionButton("Open channel", "open-channel", { + author: this.video.pubkey || "", + }, ["text-gray-100", "hover:bg-gray-700"]); + + addActionButton("Copy link", "copy-link", { + eventId: this.video.id || "", + }, ["text-gray-100", "hover:bg-gray-700"]); + + if (this.pointerInfo && this.pointerInfo.key && this.pointerInfo.pointer) { + const [pointerType, pointerValue, pointerRelay] = this.pointerInfo.pointer; + if (pointerType && pointerValue) { + const removeButton = addActionButton( + "Remove from history", + "remove-history", + { + pointerKey: this.pointerInfo.key, + pointerType, + pointerValue, + pointerRelay: pointerRelay || "", + reason: "remove-item", + }, + ["text-gray-100", "hover:bg-gray-700"] + ); + removeButton.setAttribute( + "title", + "Remove this entry from your encrypted history. Relay sync may take a moment." + ); + removeButton.setAttribute( + "aria-label", + "Remove from history (updates encrypted history and may take a moment to sync to relays)" + ); + } + } + + if (this.capabilities.canManageBlacklist) { + addActionButton( + "Blacklist creator", + "blacklist-author", + { author: this.video.pubkey || "" }, + ["text-red-400", "hover:bg-red-700", "hover:text-white"] + ); + } + + addActionButton( + "Block creator", + "block-author", + { author: this.video.pubkey || "" }, + ["text-red-400", "hover:bg-red-700", "hover:text-white"] + ); + + addActionButton( + "Report", + "report", + { eventId: this.video.id || "" }, + ["text-gray-100", "hover:bg-gray-700"] + ); + + dropdown.appendChild(list); + wrapper.appendChild(button); + wrapper.appendChild(dropdown); + + this.moreMenuButton = button; + this.moreMenu = dropdown; + + return wrapper; + } + + buildBadgesContainer() { + const pieces = []; + + if (this.playbackUrl) { + const badge = this.createElement("div", { + classNames: [ + "url-health-badge", + "text-xs", + "font-semibold", + "px-2", + "py-1", + "rounded", + "inline-flex", + "items-center", + "gap-1", + "bg-gray-800", + "text-gray-300", + ], + textContent: "Checking hosted URL…", + }); + badge.dataset.urlHealthState = "checking"; + badge.setAttribute("aria-live", "polite"); + badge.setAttribute("role", "status"); + this.urlHealthBadgeEl = badge; + pieces.push(badge); + } + + if (this.magnetSupported && this.magnetProvided) { + const badge = this.createElement("div", { + classNames: [ + "torrent-health-badge", + "text-xs", + "font-semibold", + "px-2", + "py-1", + "rounded", + "inline-flex", + "items-center", + "gap-1", + "bg-gray-800", + "text-gray-300", + "transition-colors", + "duration-200", + ], + textContent: "⏳ Torrent", + }); + badge.dataset.streamHealthState = "checking"; + badge.setAttribute("aria-live", "polite"); + badge.setAttribute("role", "status"); + this.torrentHealthBadgeEl = badge; + pieces.push(badge); + } + + if (!pieces.length) { + return null; + } + + const container = this.createElement("div", { + classNames: ["mt-3", "flex", "flex-wrap", "items-center", "gap-2"], + }); + pieces.forEach((el) => container.appendChild(el)); + return container; + } + + buildDiscussionCount() { + if (this.video.enableComments === false) { + return null; + } + + let initialCount = null; + if (typeof this.video.discussionCount === "number") { + initialCount = this.video.discussionCount; + } else if (typeof this.video.discussionCount === "string") { + const trimmed = this.video.discussionCount.trim(); + if (trimmed) { + const parsed = Number.parseInt(trimmed, 10); + if (Number.isFinite(parsed)) { + initialCount = parsed; + } + } + } + + if ( + initialCount === null || + !Number.isFinite(initialCount) || + initialCount < 0 + ) { + return null; + } + + const safeCount = Math.floor(initialCount); + const displayValue = this.helpers.toLocaleString + ? this.helpers.toLocaleString(safeCount) + : safeCount.toLocaleString(); + + const container = this.createElement("div", { + classNames: ["flex", "items-center", "text-xs", "text-gray-500", "mt-3"], + }); + container.dataset.discussionCount = this.video.id; + container.dataset.countState = "ready"; + + const valueEl = this.createElement("span", { + textContent: displayValue, + }); + valueEl.dataset.discussionCountValue = ""; + + const labelEl = this.createElement("span", { + classNames: ["ml-1"], + textContent: "notes", + }); + + container.appendChild(valueEl); + container.appendChild(labelEl); + + this.discussionCountEl = container; + + return container; + } + + applyPointerDataset() { + if (!this.root) { + return; + } + if (this.pointerInfo && this.pointerInfo.key) { + this.root.dataset.pointerKey = this.pointerInfo.key; + const pointer = Array.isArray(this.pointerInfo.pointer) + ? this.pointerInfo.pointer + : null; + if (pointer && pointer.length) { + const [type, value, relay] = pointer; + if (type) { + this.root.dataset.pointerType = type; + } + if (value) { + this.root.dataset.pointerValue = value; + } + if (relay) { + this.root.dataset.pointerRelay = relay; + } + } + } + } + + applyOwnerDataset() { + if (!this.root) { + return; + } + this.root.dataset.ownerIsViewer = this.capabilities.canEdit ? "true" : "false"; + if (this.video.pubkey) { + this.root.dataset.ownerPubkey = this.video.pubkey; + } + } + + applySourceDatasets() { + if (!this.root) { + return; + } + + if (this.playbackUrl) { + this.root.dataset.urlHealthState = "checking"; + delete this.root.dataset.urlHealthReason; + this.root.dataset.urlHealthEventId = this.video.id || ""; + this.root.dataset.urlHealthUrl = encodeURIComponent(this.playbackUrl); + } else { + this.root.dataset.urlHealthState = "offline"; + this.root.dataset.urlHealthReason = "missing-source"; + delete this.root.dataset.urlHealthEventId; + delete this.root.dataset.urlHealthUrl; + } + + if (this.magnetProvided && this.magnetSupported) { + this.root.dataset.streamHealthState = "checking"; + delete this.root.dataset.streamHealthReason; + } else { + this.root.dataset.streamHealthState = "unhealthy"; + this.root.dataset.streamHealthReason = this.magnetProvided + ? "unsupported" + : "missing-source"; + } + + if (this.magnetProvided) { + this.root.dataset.magnet = this.playbackMagnet; + } else { + delete this.root.dataset.magnet; + } + + if (this.showUnsupportedTorrentBadge) { + this.root.dataset.torrentSupported = "false"; + } else if (this.magnetProvided && this.magnetSupported) { + this.root.dataset.torrentSupported = "true"; + } + } + + applyPlaybackDatasets() { + const elements = [this.anchorEl, this.titleEl].filter(Boolean); + elements.forEach((el) => { + if (!el) return; + el.dataset.playUrl = encodeURIComponent(this.playbackUrl || ""); + el.dataset.playMagnet = this.playbackMagnet || ""; + if (this.magnetProvided) { + el.dataset.torrentSupported = this.magnetSupported ? "true" : "false"; + } else if (el.dataset.torrentSupported) { + delete el.dataset.torrentSupported; + } + }); + + if (this.urlHealthBadgeEl && this.playbackUrl) { + this.urlHealthBadgeEl.dataset.urlHealthEventId = this.video.id || ""; + this.urlHealthBadgeEl.dataset.urlHealthUrl = encodeURIComponent( + this.playbackUrl + ); + } else if (this.urlHealthBadgeEl) { + delete this.urlHealthBadgeEl.dataset.urlHealthEventId; + delete this.urlHealthBadgeEl.dataset.urlHealthUrl; + } + } + + bindEvents() { + if (this.ensureGlobalMoreMenuHandlers) { + this.ensureGlobalMoreMenuHandlers(); + } + + [this.anchorEl, this.titleEl].forEach((el) => { + if (!el) return; + el.addEventListener("click", (event) => { + if (this.callbacks.onPlay) { + this.callbacks.onPlay({ event, video: this.video, card: this }); + } + }); + }); + + if (this.settingsButton && this.settingsDropdown) { + this.settingsButton.addEventListener("click", (event) => { + event.preventDefault(); + const willOpen = this.settingsDropdown.classList.contains("hidden"); + if (this.onRequestCloseAllMenus) { + this.onRequestCloseAllMenus(this); + } else { + this.closeSettingsMenu(); + } + if (willOpen) { + this.settingsDropdown.classList.remove("hidden"); + } + }); + } + + if (this.editButton) { + this.editButton.addEventListener("click", (event) => { + event.preventDefault(); + this.closeSettingsMenu(); + if (this.callbacks.onEdit) { + this.callbacks.onEdit({ + event, + video: this.video, + index: this.index, + card: this, + }); + } + }); + } + + if (this.revertButton) { + this.revertButton.addEventListener("click", (event) => { + event.preventDefault(); + this.closeSettingsMenu(); + if (this.callbacks.onRevert) { + this.callbacks.onRevert({ + event, + video: this.video, + index: this.index, + card: this, + }); + } + }); + } + + if (this.deleteButton) { + this.deleteButton.addEventListener("click", (event) => { + event.preventDefault(); + this.closeSettingsMenu(); + if (this.callbacks.onDelete) { + this.callbacks.onDelete({ + event, + video: this.video, + index: this.index, + card: this, + }); + } + }); + } + + if (this.moreMenuButton && this.moreMenu) { + this.moreMenuButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const willOpen = this.moreMenu.classList.contains("hidden"); + if (this.onRequestCloseAllMenus) { + this.onRequestCloseAllMenus(this); + } + if (willOpen) { + this.moreMenu.classList.remove("hidden"); + this.moreMenuButton.setAttribute("aria-expanded", "true"); + } + }); + + const actionButtons = this.moreMenu.querySelectorAll( + "button[data-action]" + ); + actionButtons.forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (this.callbacks.onMoreAction) { + const dataset = { ...button.dataset }; + this.callbacks.onMoreAction({ + event, + video: this.video, + card: this, + dataset, + }); + } + this.closeMoreMenu(); + }); + }); + } + + [this.authorPicEl, this.authorNameEl].forEach((el) => { + if (!el) return; + el.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (this.callbacks.onAuthorNavigate) { + this.callbacks.onAuthorNavigate({ + event, + video: this.video, + card: this, + pubkey: this.video.pubkey || "", + }); + } + }); + }); + } + + applyClassListFromString(el, classString) { + if (!el || !classString) { + return; + } + classString + .split(/\s+/) + .map((cls) => cls.trim()) + .filter(Boolean) + .forEach((cls) => el.classList.add(cls)); + } + + createElement(tagName, { classNames = [], attrs = {}, textContent } = {}) { + const el = this.document.createElement(tagName); + classNames + .filter((cls) => typeof cls === "string" && cls.trim()) + .forEach((cls) => el.classList.add(cls)); + Object.entries(attrs || {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === "") { + return; + } + el.setAttribute(key, value); + }); + if (typeof textContent === "string") { + el.textContent = textContent; + } + return el; + } +}