diff --git a/js/app.js b/js/app.js index 8d121631..2edd7cc0 100644 --- a/js/app.js +++ b/js/app.js @@ -728,6 +728,16 @@ class Application { log: (message, ...args) => this.log(message, ...args), }, mediaLoader: this.mediaLoader, + assets: { + fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC, + }, + state: { + loadedThumbnails: this.loadedThumbnails, + }, + helpers: { + safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), + formatShortNpub: (value) => formatShortNpub(value), + }, }); if ( diff --git a/js/ui/components/SimilarContentCard.js b/js/ui/components/SimilarContentCard.js new file mode 100644 index 00000000..d17dfd67 --- /dev/null +++ b/js/ui/components/SimilarContentCard.js @@ -0,0 +1,566 @@ +import { normalizeDesignSystemContext } from "../../designSystem.js"; + +export class SimilarContentCard { + constructor({ + document: doc, + video, + index = 0, + shareUrl = "#", + pointerInfo = null, + timeAgo = "", + postedAt = null, + identity = null, + nsfwContext = null, + designSystem = null, + thumbnailCache = null, + fallbackThumbnailSrc = "", + } = {}) { + if (!doc) { + throw new Error("SimilarContentCard requires a document reference."); + } + if (!video || typeof video !== "object" || !video.id || !video.title) { + throw new Error("SimilarContentCard requires a video with id and title."); + } + + this.document = doc; + this.window = doc.defaultView || globalThis; + this.video = video; + this.index = Number.isFinite(index) ? Number(index) : 0; + this.shareUrl = + typeof shareUrl === "string" && shareUrl.trim() ? shareUrl.trim() : "#"; + this.pointerInfo = this.normalizePointerInfo(pointerInfo); + this.timeAgo = typeof timeAgo === "string" ? timeAgo : ""; + this.postedAt = this.normalizeTimestamp(postedAt); + this.identity = this.normalizeIdentity(identity); + this.nsfwContext = { + isNsfw: Boolean(nsfwContext?.isNsfw), + allowNsfw: nsfwContext?.allowNsfw !== false, + viewerIsOwner: nsfwContext?.viewerIsOwner === true, + }; + this.shouldMaskNsfwForOwner = + this.nsfwContext.isNsfw && + !this.nsfwContext.allowNsfw && + this.nsfwContext.viewerIsOwner; + + this.designSystem = normalizeDesignSystemContext(designSystem); + this.thumbnailCache = + thumbnailCache instanceof Map ? thumbnailCache : null; + this.fallbackThumbnailSrc = + typeof fallbackThumbnailSrc === "string" + ? fallbackThumbnailSrc.trim() + : ""; + + this.callbacks = { onPlay: null }; + + this.root = null; + this.mediaLinkEl = null; + this.thumbnailEl = null; + this.contentEl = null; + this.titleEl = null; + this.authorNameEl = null; + this.authorNpubEl = null; + this.timeEl = null; + this.viewCountEl = null; + + this.build(); + } + + set onPlay(fn) { + this.callbacks.onPlay = typeof fn === "function" ? fn : null; + } + + getRoot() { + return this.root; + } + + getViewCountElement() { + return this.viewCountEl; + } + + closeMoreMenu() {} + + closeSettingsMenu() {} + + updateIdentity(nextIdentity = {}) { + this.identity = this.normalizeIdentity(nextIdentity, this.identity); + + if (this.authorNameEl) { + this.authorNameEl.textContent = + this.identity.name || this.identity.shortNpub || this.identity.npub || ""; + if (this.identity.pubkey) { + this.authorNameEl.dataset.pubkey = this.identity.pubkey; + } else if (this.authorNameEl.dataset?.pubkey) { + delete this.authorNameEl.dataset.pubkey; + } + } + + if (this.authorNpubEl) { + const label = this.identity.shortNpub || this.identity.npub || ""; + this.authorNpubEl.textContent = label; + if (!label) { + this.authorNpubEl.setAttribute("aria-hidden", "true"); + } else { + this.authorNpubEl.setAttribute("aria-hidden", "false"); + } + if (this.identity.pubkey) { + this.authorNpubEl.dataset.pubkey = this.identity.pubkey; + } else if (this.authorNpubEl.dataset?.pubkey) { + delete this.authorNpubEl.dataset.pubkey; + } + } + } + + normalizePointerInfo(info) { + if (!info || typeof info !== "object") { + return null; + } + + const key = + typeof info.key === "string" && info.key.trim() ? info.key.trim() : ""; + const pointer = Array.isArray(info.pointer) ? info.pointer.slice(0, 3) : null; + + if (!key && !pointer) { + return null; + } + + return { key, pointer }; + } + + normalizeTimestamp(candidate) { + if (!Number.isFinite(candidate)) { + return null; + } + const value = Number(candidate); + const seconds = value > 1e12 ? Math.floor(value / 1000) : Math.floor(value); + return seconds; + } + + normalizeIdentity(nextIdentity = {}, fallback = null) { + const baseline = fallback && typeof fallback === "object" ? fallback : {}; + + const candidate = + nextIdentity && typeof nextIdentity === "object" ? nextIdentity : {}; + + const pubkey = (() => { + if (typeof candidate.pubkey === "string" && candidate.pubkey.trim()) { + return candidate.pubkey.trim(); + } + if (typeof baseline.pubkey === "string" && baseline.pubkey.trim()) { + return baseline.pubkey.trim(); + } + if (typeof this.video?.pubkey === "string" && this.video.pubkey.trim()) { + return this.video.pubkey.trim(); + } + return ""; + })(); + + const nameCandidates = [ + candidate.name, + candidate.displayName, + candidate.username, + baseline.name, + baseline.displayName, + baseline.username, + ]; + let name = ""; + for (const entry of nameCandidates) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (trimmed) { + name = trimmed; + break; + } + } + + const npub = (() => { + const entries = [candidate.npub, baseline.npub]; + for (const entry of entries) { + if (typeof entry === "string" && entry.trim()) { + return entry.trim(); + } + } + return ""; + })(); + + const shortNpub = (() => { + const entries = [candidate.shortNpub, baseline.shortNpub, npub]; + for (const entry of entries) { + if (typeof entry === "string" && entry.trim()) { + return entry.trim(); + } + } + return ""; + })(); + + if (!name) { + name = shortNpub || npub || ""; + } + + return { name, npub, shortNpub, pubkey }; + } + + build() { + const root = this.document.createElement("article"); + root.classList.add("player-modal__similar-card", "card"); + root.dataset.component = "similar-content-card"; + root.dataset.index = String(this.index); + if (this.video.id) { + root.dataset.videoId = this.video.id; + } + const dsMode = this.designSystem?.getMode?.(); + if (dsMode) { + root.setAttribute("data-ds", dsMode); + } + + this.root = root; + + const media = this.buildMediaSection(); + const content = this.buildContentSection(); + + if (media) { + root.appendChild(media); + } + if (content) { + root.appendChild(content); + } + + this.applyPointerDatasets(); + this.bindEvents(); + } + + buildMediaSection() { + const anchor = this.document.createElement("a"); + anchor.classList.add("player-modal__similar-card-media"); + anchor.href = this.shareUrl; + anchor.setAttribute("data-primary-action", "play"); + + const thumbnail = this.buildThumbnail(); + if (thumbnail) { + anchor.appendChild(thumbnail); + } + + this.mediaLinkEl = anchor; + return anchor; + } + + buildThumbnail() { + const img = this.document.createElement("img"); + img.decoding = "async"; + img.loading = "lazy"; + img.alt = this.video.title || ""; + img.dataset.videoThumbnail = "true"; + + const rawThumbnail = + typeof this.video.thumbnail === "string" + ? this.video.thumbnail.trim() + : ""; + const thumbnailUrl = rawThumbnail; + const fallbackSrc = this.fallbackThumbnailSrc; + const cachedValue = this.thumbnailCache?.get(this.video.id) || ""; + const shouldLazyLoad = !!thumbnailUrl && cachedValue !== thumbnailUrl; + + if (shouldLazyLoad) { + if (fallbackSrc) { + img.src = fallbackSrc; + img.dataset.fallbackSrc = fallbackSrc; + this.setCardBackdropImage(fallbackSrc); + } else { + this.setCardBackdropImage(""); + } + img.dataset.lazy = thumbnailUrl; + } else { + const initialSrc = thumbnailUrl || fallbackSrc; + if (initialSrc) { + img.src = initialSrc; + if (fallbackSrc) { + img.dataset.fallbackSrc = fallbackSrc; + } + this.setCardBackdropImage(initialSrc); + } else { + this.setCardBackdropImage(""); + } + } + + if (this.shouldMaskNsfwForOwner || this.video?.moderation?.blurThumbnail) { + img.dataset.thumbnailState = "blurred"; + } + + const handleLoad = () => { + const currentSrc = img.currentSrc || img.src || ""; + if (!currentSrc) { + return; + } + + const fallbackAttr = + (typeof img.dataset.fallbackSrc === "string" + ? img.dataset.fallbackSrc.trim() + : "") || fallbackSrc || ""; + const isFallback = + !!fallbackAttr && + (currentSrc === fallbackAttr || currentSrc.endsWith(fallbackAttr)); + + if (!isFallback) { + this.setCardBackdropImage(currentSrc); + } else if (fallbackAttr) { + this.setCardBackdropImage(fallbackAttr); + } + + if (thumbnailUrl && !isFallback && this.thumbnailCache) { + this.thumbnailCache.set(this.video.id, thumbnailUrl); + } + }; + + const handleError = () => { + const fallbackAttr = + (typeof img.dataset.fallbackSrc === "string" + ? img.dataset.fallbackSrc.trim() + : "") || fallbackSrc || ""; + if (fallbackAttr) { + this.setCardBackdropImage(fallbackAttr); + if (!img.src || img.src === thumbnailUrl) { + img.src = fallbackAttr; + } + } else { + this.setCardBackdropImage(""); + } + if (thumbnailUrl && this.thumbnailCache) { + const cached = this.thumbnailCache.get(this.video.id); + if (cached === thumbnailUrl) { + this.thumbnailCache.delete(this.video.id); + } + } + }; + + img.addEventListener("load", handleLoad); + img.addEventListener("error", handleError, { once: true }); + + if (img.complete) { + handleLoad(); + } + + this.thumbnailEl = img; + return img; + } + + buildContentSection() { + const content = this.document.createElement("div"); + content.classList.add("player-modal__similar-card-content"); + content.style.minWidth = "0"; + + const titleLink = this.document.createElement("a"); + titleLink.classList.add("player-modal__similar-card-title"); + titleLink.href = this.shareUrl; + titleLink.textContent = this.video.title || "Untitled"; + titleLink.title = this.video.title || "Untitled"; + titleLink.style.minWidth = "0"; + + const authorStack = this.buildAuthorStack(); + const metaRow = this.buildMetaRow(); + + content.appendChild(titleLink); + if (authorStack) { + content.appendChild(authorStack); + } + if (metaRow) { + content.appendChild(metaRow); + } + + this.contentEl = content; + this.titleEl = titleLink; + return content; + } + + buildAuthorStack() { + const wrapper = this.document.createElement("div"); + wrapper.classList.add("player-modal__similar-card-author"); + wrapper.style.minWidth = "0"; + + const nameEl = this.document.createElement("span"); + nameEl.classList.add("author-name", "player-modal__similar-card-author-name"); + nameEl.textContent = + this.identity.name || this.identity.shortNpub || this.identity.npub || ""; + if (this.identity.pubkey) { + nameEl.dataset.pubkey = this.identity.pubkey; + } + + const npubEl = this.document.createElement("span"); + npubEl.classList.add("author-npub", "player-modal__similar-card-author-npub"); + const npubLabel = this.identity.shortNpub || this.identity.npub || ""; + npubEl.textContent = npubLabel; + if (npubLabel) { + npubEl.setAttribute("aria-hidden", "false"); + } else { + npubEl.setAttribute("aria-hidden", "true"); + } + if (this.identity.pubkey) { + npubEl.dataset.pubkey = this.identity.pubkey; + } + + wrapper.appendChild(nameEl); + wrapper.appendChild(npubEl); + + this.authorNameEl = nameEl; + this.authorNpubEl = npubEl; + + return wrapper; + } + + buildMetaRow() { + const row = this.document.createElement("div"); + row.classList.add("player-modal__similar-card-meta"); + row.style.minWidth = "0"; + + const timeEl = this.document.createElement("time"); + timeEl.classList.add("player-modal__similar-card-timestamp"); + if (this.postedAt !== null) { + try { + const iso = new Date(this.postedAt * 1000).toISOString(); + timeEl.setAttribute("datetime", iso); + timeEl.title = new Date(this.postedAt * 1000).toLocaleString(); + } catch { + /* noop */ + } + } + timeEl.textContent = this.timeAgo || ""; + row.appendChild(timeEl); + this.timeEl = timeEl; + + const shouldShowViews = Boolean(this.pointerInfo?.key); + if (shouldShowViews) { + const separator = this.document.createElement("span"); + separator.classList.add("player-modal__similar-card-separator"); + separator.setAttribute("aria-hidden", "true"); + separator.textContent = "•"; + row.appendChild(separator); + } + + const viewEl = this.document.createElement("span"); + viewEl.classList.add("player-modal__similar-card-views", "view-count-text"); + viewEl.dataset.viewCount = ""; + viewEl.textContent = "– views"; + if (this.pointerInfo?.key) { + viewEl.dataset.viewPointer = this.pointerInfo.key; + } + row.appendChild(viewEl); + this.viewCountEl = viewEl; + + return row; + } + + applyPointerDatasets() { + if (!this.root || !this.pointerInfo) { + return; + } + + const { key, pointer } = this.pointerInfo; + if (key) { + this.root.dataset.pointerKey = key; + } + if (Array.isArray(pointer)) { + const [type, value, relay] = pointer; + if (typeof type === "string" && type) { + this.root.dataset.pointerType = type; + } + if (typeof value === "string" && value) { + this.root.dataset.pointerValue = value; + } + if (typeof relay === "string" && relay) { + this.root.dataset.pointerRelay = relay; + } + } + } + + bindEvents() { + const MouseEventCtor = this.window?.MouseEvent || globalThis.MouseEvent; + const handler = (event) => { + if (!this.callbacks.onPlay) { + return; + } + + const isMouseEvent = + typeof MouseEventCtor !== "undefined" && event instanceof MouseEventCtor; + if (isMouseEvent) { + const isPrimaryClick = + typeof event.button !== "number" || event.button === 0; + const hasModifier = + event.ctrlKey || event.metaKey || event.shiftKey || event.altKey; + if (!isPrimaryClick || hasModifier) { + return; + } + } + + event.preventDefault?.(); + event.stopPropagation?.(); + + this.callbacks.onPlay({ event, video: this.video, card: this }); + }; + + [this.mediaLinkEl, this.titleEl].forEach((el) => { + if (!el) { + return; + } + el.addEventListener("click", handler); + }); + } + + setCardBackdropImage(src) { + if (!this.root || !this.root.style) { + return; + } + + const style = this.root.style; + + const normalizeSource = (raw) => { + if (typeof raw !== "string") { + return ""; + } + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + if (/^javascript:/i.test(trimmed) || /^vbscript:/i.test(trimmed)) { + return ""; + } + if (/^(?:https?:|data:|blob:)/i.test(trimmed)) { + return trimmed; + } + if ( + trimmed.startsWith("/") || + trimmed.startsWith("./") || + trimmed.startsWith("../") || + trimmed.startsWith("assets/") + ) { + return trimmed; + } + try { + const base = + typeof this.document?.baseURI === "string" && this.document.baseURI + ? this.document.baseURI + : this.window?.location?.href || ""; + if (!base) { + return ""; + } + const resolved = new URL(trimmed, base); + if (/^(?:https?:|data:|blob:)/i.test(resolved.protocol)) { + return resolved.href; + } + } catch { + return ""; + } + return ""; + }; + + const sanitized = normalizeSource(src); + if (sanitized) { + const escaped = sanitized.replace(/(["\\])/g, "\\$1"); + style.setProperty( + "--similar-card-thumb-url", + `url("${escaped}")` + ); + } else { + style.removeProperty("--similar-card-thumb-url"); + } + } +} diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 15c01459..568cc706 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -5,7 +5,7 @@ import { attachAmbientBackground } from "../ambientBackground.js"; import { applyDesignSystemAttributes } from "../../designSystem.js"; import { devLogger } from "../../utils/logger.js"; import { renderTagPillStrip } from "./tagPillList.js"; -import { VideoCard } from "./VideoCard.js"; +import { SimilarContentCard } from "./SimilarContentCard.js"; import { subscribeToVideoViewCount, unsubscribeFromVideoViewCount, @@ -14,7 +14,7 @@ import { import { formatAbsoluteTimestamp, formatTimeAgo, - formatShortNpub, + formatShortNpub as defaultFormatShortNpub, } from "../../utils/formatters.js"; import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js"; @@ -27,6 +27,9 @@ export class VideoModal { document: doc, logger, mediaLoader, + assets = {}, + state = {}, + helpers = {}, } = {}) { if (!doc) { throw new Error("VideoModal requires a document reference."); @@ -54,6 +57,37 @@ export class VideoModal { this.mediaLoader = null; + const assetsConfig = assets && typeof assets === "object" ? assets : {}; + this.fallbackThumbnailSrc = + typeof assetsConfig.fallbackThumbnailSrc === "string" + ? assetsConfig.fallbackThumbnailSrc + : ""; + + const stateConfig = state && typeof state === "object" ? state : {}; + this.thumbnailCache = + stateConfig.loadedThumbnails instanceof Map + ? stateConfig.loadedThumbnails + : null; + + const helperConfig = helpers && typeof helpers === "object" ? helpers : {}; + const fallbackFormatShort = (value) => { + try { + return defaultFormatShortNpub(value); + } catch { + return typeof value === "string" ? value : ""; + } + }; + this.helpers = { + safeEncodeNpub: + typeof helperConfig.safeEncodeNpub === "function" + ? helperConfig.safeEncodeNpub + : () => "", + formatShortNpub: + typeof helperConfig.formatShortNpub === "function" + ? helperConfig.formatShortNpub + : fallbackFormatShort, + }; + this.loaded = false; this.playerModal = null; @@ -1593,14 +1627,14 @@ export class VideoModal { } if (!displayName && npub) { - displayName = formatShortNpub(npub); + displayName = this.helpers.formatShortNpub(npub); } if (!displayName) { displayName = "Anonymous"; } - const shortNpub = npub ? formatShortNpub(npub) : ""; + const shortNpub = npub ? this.helpers.formatShortNpub(npub) : ""; const initialCandidate = displayName.trim().charAt(0) || ""; const initial = initialCandidate ? initialCandidate.toUpperCase() : "?"; @@ -3923,10 +3957,6 @@ export class VideoModal { ? Math.floor(baseVideo.created_at) : null; - const capabilities = - item && typeof item.capabilities === "object" && item.capabilities - ? { ...item.capabilities } - : {}; const nsfwContext = item && typeof item.nsfwContext === "object" && item.nsfwContext ? { ...item.nsfwContext } @@ -3938,48 +3968,38 @@ export class VideoModal { Boolean(item?.capabilities?.canEdit), }; - const cardOptions = { - document: this.document, - video: baseVideo, - index: position, - shareUrl, - pointerInfo, - timeAgo, - postedAt: postedAtCandidate, - capabilities, - nsfwContext, - variant: "compact", - }; - - if (typeof item.cardState === "string" && item.cardState) { - cardOptions.cardState = item.cardState; - } - if (typeof item.motionState === "string" && item.motionState) { - cardOptions.motionState = item.motionState; - } - if (item.helpers && typeof item.helpers === "object") { - cardOptions.helpers = item.helpers; - } - if (item.assets && typeof item.assets === "object") { - cardOptions.assets = item.assets; - } - if (item.state && typeof item.state === "object") { - cardOptions.state = item.state; - } - if (item.designSystem) { - cardOptions.designSystem = item.designSystem; - } + const identity = this.buildSimilarCardIdentity( + baseVideo, + item?.identity || null + ); let card; try { - card = new VideoCard(cardOptions); + card = new SimilarContentCard({ + document: this.document, + video: baseVideo, + index: position, + shareUrl, + pointerInfo, + timeAgo, + postedAt: postedAtCandidate, + identity, + nsfwContext, + designSystem: item?.designSystem || null, + thumbnailCache: this.thumbnailCache, + fallbackThumbnailSrc: this.fallbackThumbnailSrc, + }); } catch (error) { this.log("[VideoModal] Failed to render similar content card", error); return; } const cardIndex = renderedCards.length; - this.prepareSimilarVideoCard(card, cardOptions, cardIndex); + this.prepareSimilarVideoCard( + card, + { video: baseVideo, pointerInfo, shareUrl }, + cardIndex + ); const root = card.getRoot(); if (!root) { @@ -4377,19 +4397,23 @@ export class VideoModal { return ""; } - prepareSimilarVideoCard(card, cardOptions, index) { + prepareSimilarVideoCard(card, meta, index) { if (!card) { return; } + const pointerInfo = meta?.pointerInfo || null; + const shareUrl = typeof meta?.shareUrl === "string" ? meta.shareUrl : "#"; + const fallbackVideo = meta?.video || null; + card.onPlay = ({ event, video: selectedVideo, card: sourceCard }) => { this.dispatch("similar:select", { event, - video: selectedVideo || cardOptions.video, + video: selectedVideo || fallbackVideo, card: sourceCard || card, index, - pointerInfo: cardOptions.pointerInfo || null, - shareUrl: cardOptions.shareUrl || "#", + pointerInfo: pointerInfo, + shareUrl, }); }; @@ -4408,6 +4432,118 @@ export class VideoModal { } } + buildSimilarCardIdentity(video, overrides) { + const override = overrides && typeof overrides === "object" ? overrides : {}; + + const pubkeyCandidates = [ + override.pubkey, + video?.pubkey, + video?.author?.pubkey, + video?.creator?.pubkey, + ]; + let pubkey = ""; + for (const candidate of pubkeyCandidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (trimmed) { + pubkey = trimmed; + break; + } + } + + const npubCandidates = [ + override.npub, + video?.npub, + video?.authorNpub, + video?.creatorNpub, + video?.profile?.npub, + ]; + let npub = ""; + for (const candidate of npubCandidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (trimmed) { + npub = trimmed; + break; + } + } + + if (!npub && pubkey) { + try { + const encoded = this.helpers.safeEncodeNpub(pubkey); + if (typeof encoded === "string" && encoded.trim()) { + npub = encoded.trim(); + } + } catch { + /* noop */ + } + } + + const shortNpubCandidates = [override.shortNpub, video?.shortNpub]; + let shortNpub = ""; + for (const candidate of shortNpubCandidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (trimmed) { + shortNpub = trimmed; + break; + } + } + + if (!shortNpub && npub) { + shortNpub = this.helpers.formatShortNpub(npub) || npub; + } + + const nameCandidates = [ + override.name, + override.displayName, + override.username, + video?.authorName, + video?.creatorName, + video?.creator?.name, + video?.author?.name, + video?.profile?.display_name, + video?.profile?.name, + video?.profile?.username, + ]; + + let name = ""; + for (const candidate of nameCandidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (!trimmed) { + continue; + } + if (HEX64_REGEX.test(trimmed)) { + continue; + } + name = trimmed; + break; + } + + if (!name && shortNpub) { + name = shortNpub; + } + if (!name && npub) { + name = npub; + } + + return { + name, + npub, + shortNpub, + pubkey, + }; + } + attachSimilarCardViewCounter(card, pointerInfo) { if (!card || typeof card.getViewCountElement !== "function") { return null; diff --git a/tests/video-modal-accessibility.test.mjs b/tests/video-modal-accessibility.test.mjs index aaa0cd31..1d247274 100644 --- a/tests/video-modal-accessibility.test.mjs +++ b/tests/video-modal-accessibility.test.mjs @@ -172,6 +172,16 @@ export async function setupModal({ lazyLoad = false } = {}) { setGlobalModalState: () => {}, document, logger: console, + assets: { + fallbackThumbnailSrc: "", + }, + state: { + loadedThumbnails: new Map(), + }, + helpers: { + safeEncodeNpub: (pubkey) => `npub:${pubkey}`, + formatShortNpub: (value) => value, + }, }); let playerModal = document.querySelector("#playerModal");